diff --git a/.coveragerc b/.coveragerc index d8041b9fe6c..d5eb32e670c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -170,6 +170,9 @@ omit = homeassistant/components/tellstick.py homeassistant/components/*/tellstick.py + homeassistant/components/tesla.py + homeassistant/components/*/tesla.py + homeassistant/components/*/thinkingcleaner.py homeassistant/components/tradfri.py @@ -328,6 +331,7 @@ omit = homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py + homeassistant/components/light/xiaomi_philipslight.py homeassistant/components/light/yeelight.py homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py @@ -380,6 +384,8 @@ omit = homeassistant/components/media_player/vlc.py homeassistant/components/media_player/volumio.py homeassistant/components/media_player/yamaha.py + homeassistant/components/media_player/yamaha_musiccast.py + homeassistant/components/mycroft.py homeassistant/components/notify/aws_lambda.py homeassistant/components/notify/aws_sns.py homeassistant/components/notify/aws_sqs.py @@ -397,6 +403,7 @@ omit = homeassistant/components/notify/llamalab_automate.py homeassistant/components/notify/matrix.py homeassistant/components/notify/message_bird.py + homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py homeassistant/components/notify/nma.py homeassistant/components/notify/prowl.py @@ -420,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py @@ -445,6 +453,7 @@ omit = homeassistant/components/sensor/dovado.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py @@ -480,6 +489,7 @@ omit = homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/modem_callerid.py + homeassistant/components/sensor/mopar.py homeassistant/components/sensor/mqtt_room.py homeassistant/components/sensor/mvglive.py homeassistant/components/sensor/netdata.py @@ -517,6 +527,7 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/synologydsm.py homeassistant/components/sensor/systemmonitor.py + homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/time_date.py diff --git a/README.rst b/README.rst index 039e8a922af..7f0d41b00ea 100644 --- a/README.rst +++ b/README.rst @@ -33,10 +33,6 @@ of a component, check the `Home Assistant help section self._probability_threshold) diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 7f2127fcad5..df488cc0ed6 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.3'] +REQUIREMENTS = ['pyhik==0.1.4'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -47,6 +47,7 @@ DEVICE_CLASS_MAP = { 'PIR Alarm': 'motion', 'Face Detection': 'motion', 'Scene Change Detection': 'motion', + 'I/O': None, } CUSTOMIZE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py index a82431a5ab8..2f464bc73cc 100644 --- a/homeassistant/components/binary_sensor/homematic.py +++ b/homeassistant/components/binary_sensor/homematic.py @@ -35,8 +35,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMBinarySensor(hass, conf) - new_device.link_homematic() + new_device = HMBinarySensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 87f8a30d78c..2b11c3fe172 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -1,21 +1,145 @@ """ -Contains functionality to use a KNX group address as a binary. +Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +import asyncio +import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES, \ + KNXAutomation +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, \ + BinarySensorDevice +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_DEVICE_CLASS = 'device_class' +CONF_SIGNIFICANT_BIT = 'significant_bit' +CONF_DEFAULT_SIGNIFICANT_BIT = 1 +CONF_AUTOMATION = 'automation' +CONF_HOOK = 'hook' +CONF_DEFAULT_HOOK = 'on' +CONF_COUNTER = 'counter' +CONF_DEFAULT_COUNTER = 1 +CONF_ACTION = 'action' + +CONF__ACTION = 'turn_off_action' + +DEFAULT_NAME = 'KNX Binary Sensor' DEPENDENCIES = ['knx'] +AUTOMATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string, + vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port, + vol.Required(CONF_ACTION, default=None): cv.SCRIPT_SCHEMA +}) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX binary sensor platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +AUTOMATIONS_SCHEMA = vol.All( + cv.ensure_list, + [AUTOMATION_SCHEMA] +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): cv.string, + vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): + cv.positive_int, + vol.Optional(CONF_AUTOMATION, default=None): AUTOMATIONS_SCHEMA, +}) -class KNXSwitch(KNXGroupAddress, BinarySensorDevice): - """Representation of a KNX binary sensor device.""" +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up binary sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False - pass + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True + + +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up binary sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXBinarySensor(hass, device)) + add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up binary senor for KNX platform configured within plattform.""" + name = config.get(CONF_NAME) + import xknx + binary_sensor = xknx.devices.BinarySensor( + hass.data[DATA_KNX].xknx, + name=name, + group_address=config.get(CONF_ADDRESS), + device_class=config.get(CONF_DEVICE_CLASS), + significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + hass.data[DATA_KNX].xknx.devices.add(binary_sensor) + + entity = KNXBinarySensor(hass, binary_sensor) + automations = config.get(CONF_AUTOMATION) + if automations is not None: + for automation in automations: + counter = automation.get(CONF_COUNTER) + hook = automation.get(CONF_HOOK) + action = automation.get(CONF_ACTION) + entity.automations.append(KNXAutomation( + hass=hass, device=binary_sensor, hook=hook, + action=action, counter=counter)) + add_devices([entity]) + + +class KNXBinarySensor(BinarySensorDevice): + """Representation of a KNX binary sensor.""" + + def __init__(self, hass, device): + """Initialization of KNXBinarySensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + self.automations = [] + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def device_class(self): + """Return the class of this sensor.""" + return self.device.device_class + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self.device.is_on() diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index 429e92afa7f..5c9a644f6b7 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -103,7 +103,8 @@ class RingBinarySensor(BinarySensorDevice): self._data.check_alerts() if self._data.alert: - self._state = (self._sensor_type == - self._data.alert.get('kind')) + if self._sensor_type == self._data.alert.get('kind') and \ + self._data.account_id == self._data.alert.get('doorbot_id'): + self._state = True else: self._state = False diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 330e8eaea9d..413804f0856 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -19,16 +19,24 @@ from homeassistant.const import ( from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import ( + async_track_state_change, async_track_same_state) from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) +CONF_DELAY_ON = 'delay_on' +CONF_DELAY_OFF = 'delay_off' + SENSOR_SCHEMA = vol.Schema({ vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(ATTR_FRIENDLY_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DELAY_ON): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_DELAY_OFF): + vol.All(cv.time_period, cv.positive_timedelta), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -47,6 +55,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): value_template.extract_entities()) friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) + delay_on = device_config.get(CONF_DELAY_ON) + delay_off = device_config.get(CONF_DELAY_OFF) if value_template is not None: value_template.hass = hass @@ -54,13 +64,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors.append( BinarySensorTemplate( hass, device, friendly_name, device_class, value_template, - entity_ids) + entity_ids, delay_on, delay_off) ) if not sensors: _LOGGER.error("No sensors added") return False - async_add_devices(sensors, True) + async_add_devices(sensors) return True @@ -68,7 +78,7 @@ class BinarySensorTemplate(BinarySensorDevice): """A virtual binary sensor that triggers from another sensor.""" def __init__(self, hass, device, friendly_name, device_class, - value_template, entity_ids): + value_template, entity_ids, delay_on, delay_off): """Initialize the Template binary sensor.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -78,6 +88,8 @@ class BinarySensorTemplate(BinarySensorDevice): self._template = value_template self._state = None self._entities = entity_ids + self._delay_on = delay_on + self._delay_off = delay_off @asyncio.coroutine def async_added_to_hass(self): @@ -89,7 +101,7 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_state_listener(entity, old_state, new_state): """Handle the target device state changes.""" - self.hass.async_add_job(self.async_update_ha_state(True)) + self.async_check_state() @callback def template_bsensor_startup(event): @@ -97,7 +109,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_state_change( self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_update_ha_state(True)) + self.hass.async_add_job(self.async_check_state) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -122,11 +134,11 @@ class BinarySensorTemplate(BinarySensorDevice): """No polling needed.""" return False - @asyncio.coroutine - def async_update(self): - """Update the state from the template.""" + @callback + def _async_render(self, *args): + """Get the state of template.""" try: - self._state = self._template.async_render().lower() == 'true' + return self._template.async_render().lower() == 'true' except TemplateError as ex: if ex.args and ex.args[0].startswith( "UndefinedError: 'None' has no attribute"): @@ -135,4 +147,29 @@ class BinarySensorTemplate(BinarySensorDevice): "the state is unknown", self._name) return _LOGGER.error("Could not render template %s: %s", self._name, ex) - self._state = False + + @callback + def async_check_state(self): + """Update the state from the template.""" + state = self._async_render() + + # return if the state don't change or is invalid + if state is None or state == self.state: + return + + @callback + def set_state(): + """Set state of template binary sensor.""" + self._state = state + self.hass.async_add_job(self.async_update_ha_state()) + + # state without delay + if (state and not self._delay_on) or \ + (not state and not self._delay_off): + set_state() + return + + period = self._delay_on if state else self._delay_off + async_track_same_state( + self.hass, state, period, set_state, entity_ids=self._entities, + async_check_func=self._async_render) diff --git a/homeassistant/components/binary_sensor/tesla.py b/homeassistant/components/binary_sensor/tesla.py new file mode 100644 index 00000000000..af7e394b50e --- /dev/null +++ b/homeassistant/components/binary_sensor/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla binary sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tesla/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla binary sensor.""" + devices = [ + TeslaBinarySensor( + device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity') + for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']] + add_devices(devices, True) + + +class TeslaBinarySensor(TeslaDevice, BinarySensorDevice): + """Implement an Tesla binary sensor for parking and charger.""" + + def __init__(self, tesla_device, controller, sensor_type): + """Initialisation of binary sensor.""" + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self._state = False + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._sensor_type = sensor_type + + @property + def device_class(self): + """Return the class of this binary sensor.""" + return self._sensor_type + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + def update(self): + """Update the state of the device.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + self._state = self.tesla_device.get_value() diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi.py index fafdc098c5d..c5f0a7b3dce 100644 --- a/homeassistant/components/binary_sensor/xiaomi.py +++ b/homeassistant/components/binary_sensor/xiaomi.py @@ -31,6 +31,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices.append(XiaomiDoorSensor(device, gateway)) elif model == 'sensor_magnet.aq2': devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'sensor_wleak.aq1': + devices.append(XiaomiWaterLeakSensor(device, gateway)) elif model == 'smoke': devices.append(XiaomiSmokeSensor(device, gateway)) elif model == 'natgas': @@ -214,6 +216,35 @@ class XiaomiDoorSensor(XiaomiBinarySensor): return False +class XiaomiWaterLeakSensor(XiaomiBinarySensor): + """Representation of a XiaomiWaterLeakSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiWaterLeakSensor.""" + XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor', + xiaomi_hub, 'status', 'moisture') + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'leak': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'no_leak': + if self._state: + self._state = False + return True + return False + + class XiaomiSmokeSensor(XiaomiBinarySensor): """Representation of a XiaomiSmokeSensor.""" diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 60cda24eef9..ce6e9580e54 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -47,8 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMThermostat(hass, conf) - new_device.link_homematic() + new_device = HMThermostat(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 4ff87aa67ab..0b2df903e17 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -196,6 +196,11 @@ class RoundThermostat(ClimateDevice): if val['id'] == self._id: data = val + except KeyError: + _LOGGER.error("Update failed from Honeywell server") + self.client.user_data = None + return + except StopIteration: _LOGGER.error("Did not receive any temperature data from the " "evohomeclient API") diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index e399e2f3dca..688ded5e7c4 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -1,68 +1,136 @@ """ -Support for KNX thermostats. +Support for KNX/IP climate devices. -For more details about this platform, please refer to the documentation +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import logging - +import asyncio import voluptuous as vol -from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice +from homeassistant.const import CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_ADDRESS = 'address' CONF_SETPOINT_ADDRESS = 'setpoint_address' CONF_TEMPERATURE_ADDRESS = 'temperature_address' +CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' +CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' +CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' +CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address' +CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \ + 'operation_mode_frost_protection_address' +CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address' +CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address' -DEFAULT_NAME = 'KNX Thermostat' +DEFAULT_NAME = 'KNX Climate' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_SETPOINT_ADDRESS): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string, + vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string, + vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXThermostat(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up climate(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): - """Representation of a KNX thermostat. +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up climates for KNX platform configured within plattform.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXClimate(hass, device)) + add_devices(entities) - A KNX thermostat will has the following parameters: - - temperature (current temperature) - - setpoint (target temperature in HASS terms) - - operation mode selection (comfort/night/frost protection) - This version supports only polling. Messages from the KNX bus do not - automatically update the state of the thermostat (to be implemented - in future releases) - """ +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up climate for KNX platform configured within plattform.""" + import xknx + climate = xknx.devices.Climate( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_temperature=config.get( + CONF_TEMPERATURE_ADDRESS), + group_address_target_temperature=config.get( + CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_setpoint=config.get( + CONF_SETPOINT_ADDRESS), + group_address_operation_mode=config.get( + CONF_OPERATION_MODE_ADDRESS), + group_address_operation_mode_state=config.get( + CONF_OPERATION_MODE_STATE_ADDRESS), + group_address_controller_status=config.get( + CONF_CONTROLLER_STATUS_ADDRESS), + group_address_controller_status_state=config.get( + CONF_CONTROLLER_STATUS_STATE_ADDRESS), + group_address_operation_mode_protection=config.get( + CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS), + group_address_operation_mode_night=config.get( + CONF_OPERATION_MODE_NIGHT_ADDRESS), + group_address_operation_mode_comfort=config.get( + CONF_OPERATION_MODE_COMFORT_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(climate) + add_devices([KNXClimate(hass, climate)]) - def __init__(self, hass, config): - """Initialize the thermostat based on the given configuration.""" - KNXMultiAddressDevice.__init__( - self, hass, config, ['temperature', 'setpoint'], ['mode']) - self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius +class KNXClimate(ClimateDevice): + """Representation of a KNX climate.""" + + def __init__(self, hass, device): + """Initialization of KNXClimate.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + self._unit_of_measurement = TEMP_CELSIUS self._away = False # not yet supported self._is_fan_on = False # not yet supported - self._current_temp = None - self._target_temp = None + + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Return the polling state, is needed for the KNX thermostat.""" - return True + """No polling needed within KNX.""" + return False @property def temperature_unit(self): @@ -72,32 +140,42 @@ class KNXThermostat(KNXMultiAddressDevice, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.device.temperature @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + if self.device.supports_target_temperature: + return self.device.target_temperature + return None - def set_temperature(self, **kwargs): + @asyncio.coroutine + def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - from knxip.conversion import float_to_knx2 + if self.device.supports_target_temperature: + yield from self.device.set_target_temperature(temperature) - self.set_value('setpoint', float_to_knx2(temperature)) - _LOGGER.debug("Set target temperature to %s", temperature) + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + if self.device.supports_operation_mode: + return self.device.operation_mode.value + return None - def set_operation_mode(self, operation_mode): + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [operation_mode.value for + operation_mode in + self.device.get_supported_operation_modes()] + + @asyncio.coroutine + def async_set_operation_mode(self, operation_mode): """Set operation mode.""" - raise NotImplementedError() - - def update(self): - """Update KNX climate.""" - from knxip.conversion import knx2_to_float - - super().update() - - self._current_temp = knx2_to_float(self.value('temperature')) - self._target_temp = knx2_to_float(self.value('setpoint')) + if self.device.supports_operation_mode: + from xknx.knx import HVACOperationMode + knx_operation_mode = HVACOperationMode(operation_mode) + yield from self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/tesla.py b/homeassistant/components/climate/tesla.py new file mode 100644 index 00000000000..39d002e72d9 --- /dev/null +++ b/homeassistant/components/climate/tesla.py @@ -0,0 +1,93 @@ +""" +Support for Tesla HVAC system. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.tesla/ +""" +import logging + +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.climate import ClimateDevice, ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import ( + TEMP_FAHRENHEIT, TEMP_CELSIUS, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +OPERATION_LIST = [STATE_ON, STATE_OFF] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla climate platform.""" + devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['climate']] + add_devices(devices, True) + + +class TeslaThermostat(TeslaDevice, ClimateDevice): + """Representation of a Tesla climate.""" + + def __init__(self, tesla_device, controller): + """Initialize the Tesla device.""" + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + self._target_temperature = None + self._temperature = None + self._name = self.tesla_device.name + + @property + def current_operation(self): + """Return current operation ie. On or Off.""" + mode = self.tesla_device.is_hvac_enabled() + if mode: + return OPERATION_LIST[0] # On + else: + return OPERATION_LIST[1] # Off + + @property + def operation_list(self): + """List of available operation modes.""" + return OPERATION_LIST + + def update(self): + """Called by the Tesla device callback to update state.""" + _LOGGER.debug("Updating: %s", self._name) + self.tesla_device.update() + self._target_temperature = self.tesla_device.get_goal_temp() + self._temperature = self.tesla_device.get_current_temp() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + _LOGGER.debug("Setting temperature for: %s", self._name) + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature: + self.tesla_device.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, cool, heat, off).""" + _LOGGER.debug("Setting mode for: %s", self._name) + if operation_mode == OPERATION_LIST[1]: # off + self.tesla_device.set_status(False) + elif operation_mode == OPERATION_LIST[0]: # heat + self.tesla_device.set_status(True) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py new file mode 100644 index 00000000000..8804f6d113f --- /dev/null +++ b/homeassistant/components/cloud/__init__.py @@ -0,0 +1,49 @@ +"""Component to integrate the Home Assistant cloud.""" +import asyncio +import logging + +import voluptuous as vol + +from . import http_api, cloud_api +from .const import DOMAIN + + +DEPENDENCIES = ['http'] +CONF_MODE = 'mode' +MODE_DEV = 'development' +MODE_STAGING = 'staging' +MODE_PRODUCTION = 'production' +DEFAULT_MODE = MODE_DEV + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=DEFAULT_MODE): + vol.In([MODE_DEV, MODE_STAGING, MODE_PRODUCTION]), + }), +}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the Home Assistant cloud.""" + mode = MODE_PRODUCTION + + if DOMAIN in config: + mode = config[DOMAIN].get(CONF_MODE) + + if mode != 'development': + _LOGGER.error('Only development mode is currently allowed.') + return False + + data = hass.data[DOMAIN] = { + 'mode': mode + } + + cloud = yield from cloud_api.async_load_auth(hass) + + if cloud is not None: + data['cloud'] = cloud + + yield from http_api.async_setup(hass) + return True diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000..6429da14516 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,297 @@ +"""Package to offer tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +import json +import logging +import os +from urllib.parse import urljoin + +import aiohttp +import async_timeout + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util.dt import utcnow + +from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS +from .util import get_mode + +_LOGGER = logging.getLogger(__name__) + + +URL_CREATE_TOKEN = 'o/token/' +URL_REVOKE_TOKEN = 'o/revoke_token/' +URL_ACCOUNT = 'account.json' + + +class CloudError(Exception): + """Base class for cloud related errors.""" + + def __init__(self, reason=None, status=None): + """Initialize a cloud error.""" + super().__init__(reason) + self.status = status + + +class Unauthenticated(CloudError): + """Raised when authentication failed.""" + + +class UnknownError(CloudError): + """Raised when an unknown error occurred.""" + + +@asyncio.coroutine +def async_load_auth(hass): + """Load authentication from disk and verify it.""" + auth = yield from hass.async_add_job(_read_auth, hass) + + if not auth: + return None + + cloud = Cloud(hass, auth) + + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + auth_check = yield from cloud.async_refresh_account_info() + + if not auth_check: + _LOGGER.error('Unable to validate credentials.') + return None + + return cloud + + except asyncio.TimeoutError: + _LOGGER.error('Unable to reach server to validate credentials.') + return None + + +@asyncio.coroutine +def async_login(hass, username, password, scope=None): + """Get a token using a username and password. + + Returns a coroutine. + """ + data = { + 'grant_type': 'password', + 'username': username, + 'password': password + } + if scope is not None: + data['scope'] = scope + + auth = yield from _async_get_token(hass, data) + + yield from hass.async_add_job(_write_auth, hass, auth) + + return Cloud(hass, auth) + + +@asyncio.coroutine +def _async_get_token(hass, data): + """Get a new token and return it as a dictionary. + + Raises exceptions when errors occur: + - Unauthenticated + - UnknownError + """ + session = async_get_clientsession(hass) + auth = aiohttp.BasicAuth(*_client_credentials(hass)) + + try: + req = yield from session.post( + _url(hass, URL_CREATE_TOKEN), + data=data, + auth=auth + ) + + if req.status == 401: + _LOGGER.error('Cloud login failed: %d', req.status) + raise Unauthenticated(status=req.status) + elif req.status != 200: + _LOGGER.error('Cloud login failed: %d', req.status) + raise UnknownError(status=req.status) + + response = yield from req.json() + response['expires_at'] = \ + (utcnow() + timedelta(seconds=response['expires_in'])).isoformat() + + return response + + except aiohttp.ClientError: + raise UnknownError() + + +class Cloud: + """Store Hass Cloud info.""" + + def __init__(self, hass, auth): + """Initialize Hass cloud info object.""" + self.hass = hass + self.auth = auth + self.account = None + + @property + def access_token(self): + """Return access token.""" + return self.auth['access_token'] + + @property + def refresh_token(self): + """Get refresh token.""" + return self.auth['refresh_token'] + + @asyncio.coroutine + def async_refresh_account_info(self): + """Refresh the account info.""" + req = yield from self.async_request('get', URL_ACCOUNT) + + if req.status != 200: + return False + + self.account = yield from req.json() + return True + + @asyncio.coroutine + def async_refresh_access_token(self): + """Get a token using a refresh token.""" + try: + self.auth = yield from _async_get_token(self.hass, { + 'grant_type': 'refresh_token', + 'refresh_token': self.refresh_token, + }) + + yield from self.hass.async_add_job( + _write_auth, self.hass, self.auth) + + return True + except CloudError: + return False + + @asyncio.coroutine + def async_revoke_access_token(self): + """Revoke active access token.""" + session = async_get_clientsession(self.hass) + client_id, client_secret = _client_credentials(self.hass) + data = { + 'token': self.access_token, + 'client_id': client_id, + 'client_secret': client_secret + } + try: + req = yield from session.post( + _url(self.hass, URL_REVOKE_TOKEN), + data=data, + ) + + if req.status != 200: + _LOGGER.error('Cloud logout failed: %d', req.status) + raise UnknownError(status=req.status) + + self.auth = None + yield from self.hass.async_add_job( + _write_auth, self.hass, None) + + except aiohttp.ClientError: + raise UnknownError() + + @asyncio.coroutine + def async_request(self, method, path, **kwargs): + """Make a request to Home Assistant cloud. + + Will refresh the token if necessary. + """ + session = async_get_clientsession(self.hass) + url = _url(self.hass, path) + + if 'headers' not in kwargs: + kwargs['headers'] = {} + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + request = yield from session.request(method, url, **kwargs) + + if request.status != 403: + return request + + # Maybe token expired. Try refreshing it. + reauth = yield from self.async_refresh_access_token() + + if not reauth: + return request + + # Release old connection back to the pool. + yield from request.release() + + kwargs['headers']['authorization'] = \ + 'Bearer {}'.format(self.access_token) + + # If we are not already fetching the account info, + # refresh the account info. + + if path != URL_ACCOUNT: + yield from self.async_refresh_account_info() + + request = yield from session.request(method, url, **kwargs) + + return request + + +def _read_auth(hass): + """Read auth file.""" + path = hass.config.path(AUTH_FILE) + + if not os.path.isfile(path): + return None + + with open(path) as file: + return json.load(file).get(get_mode(hass)) + + +def _write_auth(hass, data): + """Write auth info for specified mode. + + Pass in None for data to remove authentication for that mode. + """ + path = hass.config.path(AUTH_FILE) + mode = get_mode(hass) + + if os.path.isfile(path): + with open(path) as file: + content = json.load(file) + else: + content = {} + + if data is None: + content.pop(mode, None) + else: + content[mode] = data + + with open(path, 'wt') as file: + file.write(json.dumps(content, indent=4, sort_keys=True)) + + +def _client_credentials(hass): + """Get the client credentials. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret'] + + +def _url(hass, path): + """Generate a url for the cloud. + + Async friendly. + """ + mode = get_mode(hass) + + if mode not in SERVERS: + raise ValueError('Mode {} is not supported.'.format(mode)) + + return urljoin(SERVERS[mode]['host'], path) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py new file mode 100644 index 00000000000..f55a4be21a2 --- /dev/null +++ b/homeassistant/components/cloud/const.py @@ -0,0 +1,14 @@ +"""Constants for the cloud component.""" +DOMAIN = 'cloud' +REQUEST_TIMEOUT = 10 +AUTH_FILE = '.cloud' + +SERVERS = { + 'development': { + 'host': 'http://localhost:8000', + 'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu', + 'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4' + 'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu' + 'VBJrRyfgTVd43kbrEQtuOiaUpK') + } +} diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py new file mode 100644 index 00000000000..661cc8a7ba1 --- /dev/null +++ b/homeassistant/components/cloud/http_api.py @@ -0,0 +1,119 @@ +"""The HTTP api to control the cloud integration.""" +import asyncio +import logging + +import voluptuous as vol +import async_timeout + +from homeassistant.components.http import HomeAssistantView + +from . import cloud_api +from .const import DOMAIN, REQUEST_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass): + """Initialize the HTTP api.""" + hass.http.register_view(CloudLoginView) + hass.http.register_view(CloudLogoutView) + hass.http.register_view(CloudAccountView) + + +class CloudLoginView(HomeAssistantView): + """Login to Home Assistant cloud.""" + + url = '/api/cloud/login' + name = 'api:cloud:login' + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str, + }) + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + try: + data = yield from request.json() + except ValueError: + _LOGGER.error('Login with invalid JSON') + return self.json_message('Invalid JSON.', 400) + + try: + self.schema(data) + except vol.Invalid as err: + _LOGGER.error('Login with invalid formatted data') + return self.json_message( + 'Message format incorrect: {}'.format(err), 400) + + hass = request.app['hass'] + phase = 1 + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + cloud = yield from cloud_api.async_login( + hass, data['username'], data['password']) + + phase += 1 + + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from cloud.async_refresh_account_info() + + except cloud_api.Unauthenticated: + return self.json_message( + 'Authentication failed (phase {}).'.format(phase), 401) + except cloud_api.UnknownError: + return self.json_message( + 'Unknown error occurred (phase {}).'.format(phase), 500) + except asyncio.TimeoutError: + return self.json_message( + 'Unable to reach Home Assistant cloud ' + '(phase {}).'.format(phase), 502) + + hass.data[DOMAIN]['cloud'] = cloud + return self.json(cloud.account) + + +class CloudLogoutView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/logout' + name = 'api:cloud:logout' + + @asyncio.coroutine + def post(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + try: + with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): + yield from \ + hass.data[DOMAIN]['cloud'].async_revoke_access_token() + + hass.data[DOMAIN].pop('cloud') + + return self.json({ + 'result': 'ok', + }) + except asyncio.TimeoutError: + return self.json_message("Could not reach the server.", 502) + except cloud_api.UnknownError as err: + return self.json_message( + "Error communicating with the server ({}).".format(err.status), + 502) + + +class CloudAccountView(HomeAssistantView): + """Log out of the Home Assistant cloud.""" + + url = '/api/cloud/account' + name = 'api:cloud:account' + + @asyncio.coroutine + def get(self, request): + """Validate config and return results.""" + hass = request.app['hass'] + + if 'cloud' not in hass.data[DOMAIN]: + return self.json_message('Not logged in', 400) + + return self.json(hass.data[DOMAIN]['cloud'].account) diff --git a/homeassistant/components/cloud/util.py b/homeassistant/components/cloud/util.py new file mode 100644 index 00000000000..ec5445f0638 --- /dev/null +++ b/homeassistant/components/cloud/util.py @@ -0,0 +1,10 @@ +"""Utilities for the cloud integration.""" +from .const import DOMAIN + + +def get_mode(hass): + """Return the current mode of the cloud component. + + Async friendly. + """ + return hass.data[DOMAIN]['mode'] diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 9e447c8936a..9ce7f30529b 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,7 +14,7 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') ON_DEMAND = ('zwave') @@ -77,11 +77,11 @@ class BaseEditConfigView(HomeAssistantView): """Empty config if file not found.""" raise NotImplementedError - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" raise NotImplementedError - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" raise NotImplementedError @@ -90,7 +90,7 @@ class BaseEditConfigView(HomeAssistantView): """Fetch device specific config.""" hass = request.app['hass'] current = yield from self.read_config(hass) - value = self._get_value(current, config_key) + value = self._get_value(hass, current, config_key) if value is None: return self.json_message('Resource not found', 404) @@ -121,7 +121,7 @@ class BaseEditConfigView(HomeAssistantView): path = hass.config.path(self.path) current = yield from self.read_config(hass) - self._write_value(current, config_key, data) + self._write_value(hass, current, config_key, data) yield from hass.async_add_job(_write, path, current) @@ -149,11 +149,11 @@ class EditKeyBasedConfigView(BaseEditConfigView): """Return an empty config.""" return {} - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return data.get(config_key, {}) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" data.setdefault(config_key, {}).update(new_value) @@ -165,14 +165,14 @@ class EditIdBasedConfigView(BaseEditConfigView): """Return an empty config.""" return [] - def _get_value(self, data, config_key): + def _get_value(self, hass, data, config_key): """Get value.""" return next( (val for val in data if val.get(CONF_ID) == config_key), None) - def _write_value(self, data, config_key, new_value): + def _write_value(self, hass, data, config_key, new_value): """Set value.""" - value = self._get_value(data, config_key) + value = self._get_value(hass, data, config_key) if value is None: value = {CONF_ID: config_key} diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py new file mode 100644 index 00000000000..d25992ecc90 --- /dev/null +++ b/homeassistant/components/config/customize.py @@ -0,0 +1,39 @@ +"""Provide configuration end points for Customize.""" +import asyncio + +from homeassistant.components.config import EditKeyBasedConfigView +from homeassistant.components import async_reload_core_config +from homeassistant.config import DATA_CUSTOMIZE + +import homeassistant.helpers.config_validation as cv + +CONFIG_PATH = 'customize.yaml' + + +@asyncio.coroutine +def async_setup(hass): + """Set up the Customize config API.""" + hass.http.register_view(CustomizeConfigView( + 'customize', 'config', CONFIG_PATH, cv.entity_id, dict, + post_write_hook=async_reload_core_config + )) + + return True + + +class CustomizeConfigView(EditKeyBasedConfigView): + """Configure a list of entries.""" + + def _get_value(self, hass, data, config_key): + """Get value.""" + customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {} + return {'global': customize, 'local': data.get(config_key, {})} + + def _write_value(self, hass, data, config_key, new_value): + """Set value.""" + data[config_key] = new_value + + state = hass.states.get(config_key) + state_attributes = dict(state.attributes) + state_attributes.update(new_value) + hass.states.async_set(config_key, state.state, state_attributes) diff --git a/homeassistant/components/counter.py b/homeassistant/components/counter.py new file mode 100644 index 00000000000..64421306644 --- /dev/null +++ b/homeassistant/components/counter.py @@ -0,0 +1,220 @@ +""" +Component to count within automations. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/counter/ +""" +import asyncio +import logging +import os + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.config import load_yaml_config_file +from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.loader import bind_hass + +_LOGGER = logging.getLogger(__name__) + +ATTR_INITIAL = 'initial' +ATTR_STEP = 'step' + +CONF_INITIAL = 'initial' +CONF_STEP = 'step' + +DEFAULT_INITIAL = 0 +DEFAULT_STEP = 1 +DOMAIN = 'counter' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +SERVICE_DECREMENT = 'decrement' +SERVICE_INCREMENT = 'increment' +SERVICE_RESET = 'reset' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.Any({ + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): + cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, + }, None) + }) +}, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def increment(hass, entity_id): + """Increment a counter.""" + hass.add_job(async_increment, hass, entity_id) + + +@callback +@bind_hass +def async_increment(hass, entity_id): + """Increment a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def decrement(hass, entity_id): + """Decrement a counter.""" + hass.add_job(async_decrement, hass, entity_id) + + +@callback +@bind_hass +def async_decrement(hass, entity_id): + """Decrement a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) + + +@bind_hass +def reset(hass, entity_id): + """Reset a counter.""" + hass.add_job(async_reset, hass, entity_id) + + +@callback +@bind_hass +def async_reset(hass, entity_id): + """Reset a counter.""" + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up a counter.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + if not cfg: + cfg = {} + + name = cfg.get(CONF_NAME) + initial = cfg.get(CONF_INITIAL) + step = cfg.get(CONF_STEP) + icon = cfg.get(CONF_ICON) + + entities.append(Counter(object_id, name, initial, step, icon)) + + if not entities: + return False + + @asyncio.coroutine + def async_handler_service(service): + """Handle a call to the counter services.""" + target_counters = component.async_extract_from_service(service) + + if service.service == SERVICE_INCREMENT: + attr = 'async_increment' + elif service.service == SERVICE_DECREMENT: + attr = 'async_decrement' + elif service.service == SERVICE_RESET: + attr = 'async_reset' + + tasks = [getattr(counter, attr)() for counter in target_counters] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + descriptions = yield from hass.async_add_job( + load_yaml_config_file, os.path.join( + os.path.dirname(__file__), 'services.yaml') + ) + + hass.services.async_register( + DOMAIN, SERVICE_INCREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_INCREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_DECREMENT, async_handler_service, + descriptions[DOMAIN][SERVICE_DECREMENT], SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_RESET, async_handler_service, + descriptions[DOMAIN][SERVICE_RESET], SERVICE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class Counter(Entity): + """Representation of a counter.""" + + def __init__(self, object_id, name, initial, step, icon): + """Initialize a counter.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._step = step + self._state = self._initial = initial + self._icon = icon + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return name of the counter.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the current value of the counter.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_INITIAL: self._initial, + ATTR_STEP: self._step, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Call when entity about to be added to Home Assistant.""" + # If not None, we got an initial value. + if self._state is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + self._state = state and state.state == state + + @asyncio.coroutine + def async_decrement(self): + """Decrement the counter.""" + self._state -= self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_increment(self): + """Increment a counter.""" + self._state += self._step + yield from self.async_update_ha_state() + + @asyncio.coroutine + def async_reset(self): + """Reset a counter.""" + self._state = self._initial + yield from self.async_update_ha_state() diff --git a/homeassistant/components/cover/abode.py b/homeassistant/components/cover/abode.py new file mode 100644 index 00000000000..b09c9e5e007 --- /dev/null +++ b/homeassistant/components/cover/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA cover support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.cover import CoverDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode cover devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)): + sensors.append(AbodeCover(abode, sensor)) + + add_devices(sensors) + + +class AbodeCover(AbodeDevice, CoverDevice): + """Representation of an Abode cover.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self._device.is_open is False + + def close_cover(self): + """Issue close command to cover.""" + self._device.close_cover() + + def open_cover(self): + """Issue open command to cover.""" + self._device.open_cover() diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py index e8372b84ce4..9e3d675cabe 100644 --- a/homeassistant/components/cover/homematic.py +++ b/homeassistant/components/cover/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMCover(hass, conf) - new_device.link_homematic() + new_device = HMCover(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 4883cfe3648..e4c2931983d 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -1,185 +1,239 @@ """ -Support for KNX covers. +Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import logging - +import asyncio import voluptuous as vol +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.event import async_track_utc_time_change from homeassistant.components.cover import ( - CoverDevice, PLATFORM_SCHEMA, ATTR_POSITION, DEVICE_CLASSES_SCHEMA, - SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, SUPPORT_STOP, - SUPPORT_SET_TILT_POSITION -) -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.const import (CONF_NAME, CONF_DEVICE_CLASS) + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_SET_POSITION, SUPPORT_STOP, SUPPORT_SET_TILT_POSITION, + ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.core import callback +from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) - -CONF_GETPOSITION_ADDRESS = 'getposition_address' -CONF_SETPOSITION_ADDRESS = 'setposition_address' -CONF_GETANGLE_ADDRESS = 'getangle_address' -CONF_SETANGLE_ADDRESS = 'setangle_address' -CONF_STOP = 'stop_address' -CONF_UPDOWN = 'updown_address' +CONF_MOVE_LONG_ADDRESS = 'move_long_address' +CONF_MOVE_SHORT_ADDRESS = 'move_short_address' +CONF_POSITION_ADDRESS = 'position_address' +CONF_POSITION_STATE_ADDRESS = 'position_state_address' +CONF_ANGLE_ADDRESS = 'angle_address' +CONF_ANGLE_STATE_ADDRESS = 'angle_state_address' +CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down' +CONF_TRAVELLING_TIME_UP = 'travelling_time_up' CONF_INVERT_POSITION = 'invert_position' CONF_INVERT_ANGLE = 'invert_angle' +DEFAULT_TRAVEL_TIME = 25 DEFAULT_NAME = 'KNX Cover' DEPENDENCIES = ['knx'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_UPDOWN): cv.string, - vol.Required(CONF_STOP): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GETPOSITION_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SETPOSITION_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string, + vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_ADDRESS): cv.string, + vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_ADDRESS): cv.string, + vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, + vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME): + cv.positive_int, vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean, - vol.Inclusive(CONF_GETANGLE_ADDRESS, 'angle'): cv.string, - vol.Inclusive(CONF_SETANGLE_ADDRESS, 'angle'): cv.string, vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Create and add an entity based on the configuration.""" - add_devices([KNXCover(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up cover(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXCover(KNXMultiAddressDevice, CoverDevice): - """Representation of a KNX cover. e.g. a rollershutter.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up covers for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXCover(hass, device)) + add_devices(entities) - def __init__(self, hass, config): + +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up cover for KNX platform configured within plattform.""" + import xknx + cover = xknx.devices.Cover( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_long=config.get(CONF_MOVE_LONG_ADDRESS), + group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS), + group_address_position_state=config.get( + CONF_POSITION_STATE_ADDRESS), + group_address_angle=config.get(CONF_ANGLE_ADDRESS), + group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS), + group_address_position=config.get(CONF_POSITION_ADDRESS), + travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN), + travel_time_up=config.get(CONF_TRAVELLING_TIME_UP)) + + invert_position = config.get(CONF_INVERT_POSITION) + invert_angle = config.get(CONF_INVERT_ANGLE) + hass.data[DATA_KNX].xknx.devices.add(cover) + add_devices([KNXCover(hass, cover, invert_position, invert_angle)]) + + +class KNXCover(CoverDevice): + """Representation of a KNX cover.""" + + def __init__(self, hass, device, invert_position=False, + invert_angle=False): """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - ['updown', 'stop'], # required - optional=['setposition', 'getposition', - 'getangle', 'setangle'] - ) - self._device_class = config.config.get(CONF_DEVICE_CLASS) - self._invert_position = config.config.get(CONF_INVERT_POSITION) - self._invert_angle = config.config.get(CONF_INVERT_ANGLE) - self._hass = hass - self._current_pos = None - self._target_pos = None - self._current_tilt = None - self._target_tilt = None - self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ - SUPPORT_SET_POSITION | SUPPORT_STOP + self.device = device + self.invert_position = invert_position + self.invert_angle = invert_angle + self.hass = hass + self.async_register_callbacks() - # Tilt is only supported, if there is a angle get and set address - if CONF_SETANGLE_ADDRESS in config.config: - _LOGGER.debug("%s: Tilt supported at addresses %s, %s", - self.name, config.config.get(CONF_SETANGLE_ADDRESS), - config.config.get(CONF_GETANGLE_ADDRESS)) - self._supported_features = self._supported_features | \ - SUPPORT_SET_TILT_POSITION + self._unsubscribe_auto_updater = None + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name @property def should_poll(self): - """Polling is needed for the KNX cover.""" - return True + """No polling needed within KNX.""" + return False @property def supported_features(self): """Flag supported features.""" - return self._supported_features + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \ + SUPPORT_SET_POSITION | SUPPORT_STOP + if self.device.supports_angle: + supported_features |= SUPPORT_SET_TILT_POSITION + return supported_features + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return int(self.from_knx_position( + self.device.current_position(), + self.invert_position)) @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - if self.current_cover_position > 0: - return False - else: - return True + return self.device.is_closed() - @property - def current_cover_position(self): - """Return current position of cover. + @asyncio.coroutine + def async_close_cover(self, **kwargs): + """Close the cover.""" + if not self.device.is_closed(): + yield from self.device.set_down() + self.start_auto_updater() - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_pos + @asyncio.coroutine + def async_open_cover(self, **kwargs): + """Open the cover.""" + if not self.device.is_open(): + yield from self.device.set_up() + self.start_auto_updater() - @property - def target_position(self): - """Return the position we are trying to reach: 0 - 100.""" - return self._target_pos + @asyncio.coroutine + def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + knx_position = self.to_knx_position(position, self.invert_position) + yield from self.device.set_position(knx_position) + self.start_auto_updater() + + @asyncio.coroutine + def async_stop_cover(self, **kwargs): + """Stop the cover.""" + yield from self.device.stop() + self.stop_auto_updater() @property def current_cover_tilt_position(self): - """Return current position of cover. + """Return current tilt position of cover.""" + if not self.device.supports_angle: + return None + return int(self.from_knx_position( + self.device.angle, + self.invert_angle)) - None is unknown, 0 is closed, 100 is fully open. - """ - return self._current_tilt + @asyncio.coroutine + def async_set_cover_tilt_position(self, **kwargs): + """Move the cover tilt to a specific position.""" + if ATTR_TILT_POSITION in kwargs: + position = kwargs[ATTR_TILT_POSITION] + knx_position = self.to_knx_position(position, self.invert_angle) + yield from self.device.set_angle(knx_position) - @property - def target_tilt(self): - """Return the tilt angle (in %) we are trying to reach: 0 - 100.""" - return self._target_tilt + def start_auto_updater(self): + """Start the autoupdater to update HASS while cover is moving.""" + if self._unsubscribe_auto_updater is None: + self._unsubscribe_auto_updater = async_track_utc_time_change( + self.hass, self.auto_updater_hook) - def set_cover_position(self, **kwargs): - """Set new target position.""" - position = kwargs.get(ATTR_POSITION) - if position is None: - return + def stop_auto_updater(self): + """Stop the autoupdater.""" + if self._unsubscribe_auto_updater is not None: + self._unsubscribe_auto_updater() + self._unsubscribe_auto_updater = None - if self._invert_position: - position = 100-position + @callback + def auto_updater_hook(self, now): + """Callback for autoupdater.""" + # pylint: disable=unused-argument + self.hass.async_add_job(self.async_update_ha_state()) + if self.device.position_reached(): + self.stop_auto_updater() - self._target_pos = position - self.set_percentage('setposition', position) - _LOGGER.debug("%s: Set target position to %d", self.name, position) + self.hass.add_job(self.device.auto_stop_if_necessary()) - def update(self): - """Update device state.""" - super().update() - value = self.get_percentage('getposition') - if value is not None: - self._current_pos = value - if self._invert_position: - self._current_pos = 100-value - _LOGGER.debug("%s: position = %d", self.name, value) + @staticmethod + def from_knx_position(raw, invert): + """Convert KNX position [0...255] to hass position [100...0].""" + position = round((raw/256)*100) + if not invert: + position = 100 - position + return position - if self._supported_features & SUPPORT_SET_TILT_POSITION: - value = self.get_percentage('getangle') - if value is not None: - self._current_tilt = value - if self._invert_angle: - self._current_tilt = 100-value - _LOGGER.debug("%s: tilt = %d", self.name, value) - - def open_cover(self, **kwargs): - """Open the cover.""" - _LOGGER.debug("%s: open: updown = 0", self.name) - self.set_int_value('updown', 0) - - def close_cover(self, **kwargs): - """Close the cover.""" - _LOGGER.debug("%s: open: updown = 1", self.name) - self.set_int_value('updown', 1) - - def stop_cover(self, **kwargs): - """Stop the cover movement.""" - _LOGGER.debug("%s: stop: stop = 1", self.name) - self.set_int_value('stop', 1) - - def set_cover_tilt_position(self, tilt_position, **kwargs): - """Move the cover til to a specific position.""" - if self._invert_angle: - tilt_position = 100-tilt_position - - self._target_tilt = round(tilt_position, -1) - self.set_percentage('setangle', tilt_position) - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return self._device_class + @staticmethod + def to_knx_position(value, invert): + """Convert hass position [100...0] to KNX position [0...255].""" + knx_position = round(value/100*255.4) + if not invert: + knx_position = 255-knx_position + print(value, " -> ", knx_position) + return knx_position diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 648dba98ca6..31e4f1e3cf2 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -1,14 +1,14 @@ """ -Support for Lutron Caseta SerenaRollerShade. +Support for Lutron Caseta shades. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ import logging - from homeassistant.components.cover import ( - CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION) + CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_SET_POSITION, + ATTR_POSITION, DOMAIN) from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) @@ -19,11 +19,10 @@ DEPENDENCIES = ['lutron_caseta'] # pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Lutron Caseta Serena shades as a cover device.""" + """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", - "SerenaHoneycombShade"]) + cover_devices = bridge.get_devices_by_domain(DOMAIN) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) @@ -32,7 +31,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class LutronCasetaCover(LutronCasetaDevice, CoverDevice): - """Representation of a Lutron Serena shade.""" + """Representation of a Lutron shade.""" @property def supported_features(self): @@ -42,24 +41,26 @@ class LutronCasetaCover(LutronCasetaDevice, CoverDevice): @property def is_closed(self): """Return if the cover is closed.""" - return self._state["current_state"] < 1 + return self._state['current_state'] < 1 @property def current_cover_position(self): """Return the current position of cover.""" - return self._state["current_state"] + return self._state['current_state'] - def close_cover(self): + def close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - def open_cover(self): + def open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - def set_cover_position(self, position, **kwargs): - """Move the roller shutter to a specific position.""" - self._smartbridge.set_value(self._device_id, position) + def set_cover_position(self, **kwargs): + """Move the shade to a specific position.""" + if ATTR_POSITION in kwargs: + position = kwargs[ATTR_POSITION] + self._smartbridge.set_value(self._device_id, position) def update(self): """Call when forcing a refresh of the device.""" diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index f599ea3ede1..0e28d3ef701 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -16,7 +16,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the RFXtrx cover.""" import RFXtrx as rfxtrxmod - covers = rfxtrx.get_devices_from_config(config, RfxtrxCover, hass) + covers = rfxtrx.get_devices_from_config(config, RfxtrxCover) add_devices_callback(covers) def cover_update(event): @@ -26,7 +26,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): not event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxCover, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxCover) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi.py index 7e3b0b7044d..d0e7bfa6d7e 100644 --- a/homeassistant/components/cover/xiaomi.py +++ b/homeassistant/components/cover/xiaomi.py @@ -24,10 +24,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class XiaomiGenericCover(XiaomiDevice, CoverDevice): - """Representation of a XiaomiPlug.""" + """Representation of a XiaomiGenericCover.""" def __init__(self, device, name, data_key, xiaomi_hub): - """Initialize the XiaomiPlug.""" + """Initialize the XiaomiGenericCover.""" self._data_key = data_key self._pos = 0 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -44,19 +44,19 @@ class XiaomiGenericCover(XiaomiDevice, CoverDevice): def close_cover(self, **kwargs): """Close the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'close') + self._write_to_hub(self._sid, **{self._data_key['status']: 'close'}) def open_cover(self, **kwargs): """Open the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'open') + self._write_to_hub(self._sid, **{self._data_key['status']: 'open'}) def stop_cover(self, **kwargs): """Stop the cover.""" - self._write_to_hub(self._sid, self._data_key['status'], 'stop') + self._write_to_hub(self._sid, **{self._data_key['status']: 'stop'}) def set_cover_position(self, position, **kwargs): """Move the cover to a specific position.""" - self._write_to_hub(self._sid, self._data_key['pos'], str(position)) + self._write_to_hub(self._sid, **{self._data_key['pos']: str(position)}) def parse_data(self, data): """Parse data sent by gateway.""" diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index a4495926f82..6ae038fd41c 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -205,6 +205,7 @@ class AutomaticData(object): self.hass = hass self.devices = devices self.vehicle_info = {} + self.vehicle_seen = {} self.client = client self.session = session self.async_see = async_see @@ -236,6 +237,14 @@ class AutomaticData(object): return yield from self.get_vehicle_info(vehicle) + if event.created_at < self.vehicle_seen[event.vehicle.id]: + # Skip events received out of order + _LOGGER.debug("Skipping out of order event. Event Created %s. " + "Last seen event: %s.", event.created_at, + self.vehicle_seen[event.vehicle.id]) + return + self.vehicle_seen[event.vehicle.id] = event.created_at + kwargs = self.vehicle_info[event.vehicle.id] if kwargs is None: # Ignored device @@ -323,15 +332,17 @@ class AutomaticData(object): if self.devices is not None and name not in self.devices: self.vehicle_info[vehicle.id] = None return - else: - self.vehicle_info[vehicle.id] = kwargs = { - ATTR_DEV_ID: vehicle.id, - ATTR_HOST_NAME: name, - ATTR_MAC: vehicle.id, - ATTR_ATTRIBUTES: { - ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, - } + + self.vehicle_info[vehicle.id] = kwargs = { + ATTR_DEV_ID: vehicle.id, + ATTR_HOST_NAME: name, + ATTR_MAC: vehicle.id, + ATTR_ATTRIBUTES: { + ATTR_FUEL_LEVEL: vehicle.fuel_level_percent, } + } + self.vehicle_seen[vehicle.id] = \ + vehicle.updated_at or vehicle.created_at if vehicle.latest_location is not None: location = vehicle.latest_location @@ -352,4 +363,7 @@ class AutomaticData(object): kwargs[ATTR_GPS] = (location.lat, location.lon) kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m + if trips[0].ended_at >= self.vehicle_seen[vehicle.id]: + self.vehicle_seen[vehicle.id] = trips[0].ended_at + return kwargs diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py new file mode 100755 index 00000000000..d4e576bad74 --- /dev/null +++ b/homeassistant/components/device_tracker/geofency.py @@ -0,0 +1,127 @@ +""" +Support for the Geofency platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.geofency/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +BEACON_DEV_PREFIX = 'beacon' +CONF_MOBILE_BEACONS = 'mobile_beacons' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MOBILE_BEACONS): vol.All( + cv.ensure_list, [cv.string]), +}) + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up an endpoint for the Geofency application.""" + mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] + + hass.http.register_view(GeofencyView(see, mobile_beacons)) + + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, see, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.see = see + self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] + + @asyncio.coroutine + def post(self, request): + """Handle Geofency requests.""" + data = yield from request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) + + if self._is_mobile_beacon(data): + return (yield from self._set_location(hass, data, None)) + else: + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + + return (yield from self._set_location(hass, data, location_name)) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return False + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + data[ATTR_LATITUDE] = float(data[ATTR_LATITUDE]) + data[ATTR_LONGITUDE] = float(data[ATTR_LONGITUDE]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + else: + return data['device'] + + @asyncio.coroutine + def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + yield from hass.async_add_job( + partial(self.see, dev_id=device, + gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name=location_name, + attributes=data)) + + return "Setting location for {}".format(device) diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py new file mode 100644 index 00000000000..4945e98a94d --- /dev/null +++ b/homeassistant/components/device_tracker/tesla.py @@ -0,0 +1,57 @@ +""" +Support for the Tesla platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.tesla/ +""" +import logging + +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN +from homeassistant.helpers.event import track_utc_time_change +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the Tesla tracker.""" + TeslaDeviceTracker( + hass, config, see, + hass.data[TESLA_DOMAIN]['devices']['devices_tracker']) + return True + + +class TeslaDeviceTracker(object): + """A class representing a Tesla device.""" + + def __init__(self, hass, config, see, tesla_devices): + """Initialize the Tesla device scanner.""" + self.hass = hass + self.see = see + self.devices = tesla_devices + self._update_info() + + track_utc_time_change( + self.hass, self._update_info, second=range(0, 60, 30)) + + def _update_info(self, now=None): + """Update the device info.""" + for device in self.devices: + device.update() + name = device.name + _LOGGER.debug("Updating device position: %s", name) + dev_id = slugify(device.uniq_name) + location = device.get_location() + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 4312c5dd54a..7872f8f1f1c 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -20,11 +20,12 @@ def setup_scanner(hass, config, see, discovery_info=None): return vin, _ = discovery_info - vehicle = hass.data[DATA_KEY].vehicles[vin] + voc = hass.data[DATA_KEY] + vehicle = voc.vehicles[vin] def see_vehicle(vehicle): """Handle the reporting of the vehicle position.""" - host_name = vehicle.registration_number + host_name = voc.vehicle_name(vehicle) dev_id = 'volvo_{}'.format(slugify(host_name)) see(dev_id=dev_id, host_name=host_name, diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 06e6f0b989a..c757d9d1ce3 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -100,6 +100,7 @@ def async_setup(hass, config): # We do not know how to handle this service. if not comp_plat: + logger.info("Unknown service discovered: %s %s", service, info) return discovery_hash = json.dumps([service, info], sort_keys=True) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 29f6ef577e5..112c93403b0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -28,6 +28,7 @@ URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static/') ATTR_THEMES = 'themes' +ATTR_EXTRA_HTML_URL = 'extra_html_url' DEFAULT_THEME_COLOR = '#03A9F4' MANIFEST_JSON = { 'background_color': '#FFFFFF', @@ -50,6 +51,7 @@ for size in (192, 384, 512, 1024): }) DATA_PANELS = 'frontend_panels' +DATA_EXTRA_HTML_URL = 'frontend_extra_html_url' DATA_INDEX_VIEW = 'frontend_index_view' DATA_THEMES = 'frontend_themes' DATA_DEFAULT_THEME = 'frontend_default_theme' @@ -66,6 +68,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(ATTR_THEMES): vol.Schema({ cv.string: {cv.string: cv.string} }), + vol.Optional(ATTR_EXTRA_HTML_URL): + vol.All(cv.ensure_list, [cv.string]), }), }, extra=vol.ALLOW_EXTRA) @@ -105,14 +109,13 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, component_name: name of the web component path: path to the HTML of the web component + (required unless url is provided) md5: the md5 hash of the web component (for versioning, optional) sidebar_title: title to show in the sidebar (optional) sidebar_icon: icon to show next to title in sidebar (optional) url_path: name to use in the url (defaults to component_name) - url: for the web component (for dev environment, optional) + url: for the web component (optional) config: config to be passed into the web component - - Warning: this API will probably change. Use at own risk. """ panels = hass.data.get(DATA_PANELS) if panels is None: @@ -123,14 +126,16 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, if url_path in panels: _LOGGER.warning("Overwriting component %s", url_path) - if not os.path.isfile(path): - _LOGGER.error( - "Panel %s component does not exist: %s", component_name, path) - return - if md5 is None: - with open(path) as fil: - md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() + if url is None: + if not os.path.isfile(path): + _LOGGER.error( + "Panel %s component does not exist: %s", component_name, path) + return + + if md5 is None: + with open(path) as fil: + md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest() data = { 'url_path': url_path, @@ -169,6 +174,15 @@ def register_panel(hass, component_name, path, md5=None, sidebar_title=None, 'get', '/{}/{{extra:.+}}'.format(url_path), index_view.get) +@bind_hass +def add_extra_html_url(hass, url): + """Register extra html url to load.""" + url_set = hass.data.get(DATA_EXTRA_HTML_URL) + if url_set is None: + url_set = hass.data[DATA_EXTRA_HTML_URL] = set() + url_set.add(url) + + def add_manifest_json_key(key, val): """Add a keyval to the manifest.json.""" MANIFEST_JSON[key] = val @@ -208,6 +222,9 @@ def setup(hass, config): else: hass.data[DATA_PANELS] = {} + if DATA_EXTRA_HTML_URL not in hass.data: + hass.data[DATA_EXTRA_HTML_URL] = set() + register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location') for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', @@ -217,6 +234,9 @@ def setup(hass, config): themes = config.get(DOMAIN, {}).get(ATTR_THEMES) setup_themes(hass, themes) + for url in config.get(DOMAIN, {}).get(ATTR_EXTRA_HTML_URL, []): + add_extra_html_url(hass, url) + return True @@ -362,7 +382,9 @@ class IndexView(HomeAssistantView): compatibility_url=compatibility_url, no_auth=no_auth, icons_url=icons_url, icons=FINGERPRINTS['mdi.html'], panel_url=panel_url, panels=hass.data[DATA_PANELS], - dev_mode=request.app[KEY_DEVELOPMENT]) + dev_mode=request.app[KEY_DEVELOPMENT], + theme_color=MANIFEST_JSON['theme_color'], + extra_urls=hass.data[DATA_EXTRA_HTML_URL]) return web.Response(text=resp, content_type='text/html') diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html index 6420bb79739..6d199a86a50 100644 --- a/homeassistant/components/frontend/templates/index.html +++ b/homeassistant/components/frontend/templates/index.html @@ -21,7 +21,7 @@ - + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 9c8b72d6bcc..08a7f5002cd 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html index d55e008d907..53638dd582b 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html @@ -1,2 +1,2 @@ \ No newline at end of file + clear: both;white-space:pre-wrap;}.rendered.error{color:red;}
Templates

Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.

[[processed]]
\ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz index 9d8f4d9f5eb..24fd95f17a7 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html index e2a93ae1cea..5f34f7bc28a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz index 801450f1bd8..d9dd4c687fb 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index a5544a8b165..dc4770853e0 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -37,7 +37,7 @@ /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 'use strict'; -var precacheConfig = [["/","535d629ec4d3936dba0ca4ca84dabeb2"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-f47b6910d8e4880e22cc508ca452f9b6.html","9aa0675e01373c6bc2737438bb84a9ec"],["/frontend/panels/map-c2544fff3eedb487d44105cf94b335ec.html","113c5bf9a68a74c62e50cd354034e78b"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-6c8192a4393c9e83516dc8177b75c23d.html","56d5bfe9e11a8b81a686f20aeae3c359"],["/static/mdi-e91f61a039ed0a9936e7ee5360da3870.html","5e587bc82719b740a4f0798722a83aee"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; +var precacheConfig = [["/","eceffe0debe81636e1eb8604e6eefbd6"],["/frontend/panels/dev-event-d409e7ab537d9fe629126d122345279c.html","936814991f2a5e23d61d29f0d40f81b8"],["/frontend/panels/dev-info-b0e55eb657fd75f21aba2426ac0cedc0.html","1fa953b0224470f70d4e87bbe4dff191"],["/frontend/panels/dev-mqtt-94b222b013a98583842de3e72d5888c6.html","dc3ddfac58397feda97317358f0aecbb"],["/frontend/panels/dev-service-422b2c181ee0713fa31d45a64e605baf.html","ae7d26b1c8c3309fd3c65944f89ea03f"],["/frontend/panels/dev-state-7948d3dba058f31517d880df8ed0e857.html","ff8156bb1a52490fcc07466556fce0e1"],["/frontend/panels/dev-template-928e7b81b9c113b70edc9f4a1d051827.html","312c8313800b44c83bcb8dc2df30c759"],["/frontend/panels/map-565db019147162080c21af962afc097f.html","a1a360042395682335e2f471dddad309"],["/static/compatibility-1686167ff210e001f063f5c606b2e74b.js","6ee7b5e2dd82b510c3bd92f7e215988e"],["/static/core-2a7d01e45187c7d4635da05065b5e54e.js","90a0a8a6a6dd0ca41b16f40e7d23924d"],["/static/frontend-c04709d3517dd3fd34b2f7d6bba6ec8e.html","e072f7bbe595bcb104d117a45592459d"],["/static/mdi-89074face5529f5fe6fbae49ecb3e88b.html","97754e463f9e56a95c813d4d8e792347"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"]]; var cacheName = 'sw-precache-v3--' + (self.registration ? self.registration.scope : ''); diff --git a/homeassistant/components/frontend/www_static/service_worker.js.gz b/homeassistant/components/frontend/www_static/service_worker.js.gz index 97665c7f31e..14edb98db2b 100644 Binary files a/homeassistant/components/frontend/www_static/service_worker.js.gz and b/homeassistant/components/frontend/www_static/service_worker.js.gz differ diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 9989b2799cd..b4233f1ac82 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -31,7 +31,7 @@ DOMAIN = 'hdmi_cec' _LOGGER = logging.getLogger(__name__) -DEFAULT_DISPLAY_NAME = "HomeAssistant" +DEFAULT_DISPLAY_NAME = "HA" CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' @@ -181,7 +181,7 @@ def setup(hass: HomeAssistant, base_config): if host: adapter = TcpAdapter(host, name=display_name, activate_source=False) else: - adapter = CecAdapter(name=display_name, activate_source=False) + adapter = CecAdapter(name=display_name[:12], activate_source=False) hdmi_network = HDMINetwork(adapter, loop=loop) def _volume(call): diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index f9583d9be7a..dc5e641cbba 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -4,8 +4,8 @@ Support for HomeMatic devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homematic/ """ +import asyncio import os -import time import logging from datetime import timedelta from functools import partial @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_HOSTS, CONF_NAME, ATTR_ENTITY_ID) from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.config import load_yaml_config_file REQUIREMENTS = ['pyhomematic==0.1.30'] @@ -121,7 +121,6 @@ CONF_RESOLVENAMES_OPTIONS = [ ] DATA_HOMEMATIC = 'homematic' -DATA_DELAY = 'homematic_delay' DATA_DEVINIT = 'homematic_devinit' DATA_STORE = 'homematic_store' @@ -134,7 +133,6 @@ CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' -CONF_DELAY = 'delay' CONF_PRIMARY = 'primary' DEFAULT_LOCAL_IP = '0.0.0.0' @@ -145,7 +143,6 @@ DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' DEFAULT_VARIABLES = False DEFAULT_DEVICES = True -DEFAULT_DELAY = 0.5 DEFAULT_PRIMARY = False @@ -177,7 +174,6 @@ CONFIG_SCHEMA = vol.Schema({ }}, vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string, vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port, - vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float), }), }, extra=vol.ALLOW_EXTRA) @@ -249,7 +245,6 @@ def setup(hass, config): """Set up the Homematic component.""" from pyhomematic import HMConnection - hass.data[DATA_DELAY] = config[DOMAIN].get(CONF_DELAY) hass.data[DATA_DEVINIT] = {} hass.data[DATA_STORE] = set() @@ -277,7 +272,7 @@ def setup(hass, config): # Create server thread bound_system_callback = partial(_system_callback_handler, hass, config) - hass.data[DATA_HOMEMATIC] = HMConnection( + hass.data[DATA_HOMEMATIC] = homematic = HMConnection( local=config[DOMAIN].get(CONF_LOCAL_IP), localport=config[DOMAIN].get(CONF_LOCAL_PORT), remotes=remotes, @@ -286,7 +281,7 @@ def setup(hass, config): ) # Start server thread, connect to hosts, initialize to receive events - hass.data[DATA_HOMEMATIC].start() + homematic.start() # Stops server when HASS is shutting down hass.bus.listen_once( @@ -296,7 +291,7 @@ def setup(hass, config): entity_hubs = [] for _, hub_data in hosts.items(): entity_hubs.append(HMHub( - hass, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) + homematic, hub_data[CONF_NAME], hub_data[CONF_VARIABLES])) # Register HomeMatic services descriptions = load_yaml_config_file( @@ -359,7 +354,7 @@ def setup(hass, config): def _service_handle_reconnect(service): """Service to reconnect all HomeMatic hubs.""" - hass.data[DATA_HOMEMATIC].reconnect() + homematic.reconnect() hass.services.register( DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect, @@ -575,24 +570,27 @@ def _device_from_servicecall(hass, service): class HMHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" - def __init__(self, hass, name, use_variables): + def __init__(self, homematic, name, use_variables): """Initialize HomeMatic hub.""" - self.hass = hass self.entity_id = "{}.{}".format(DOMAIN, name.lower()) - self._homematic = hass.data[DATA_HOMEMATIC] + self._homematic = homematic self._variables = {} self._name = name self._state = STATE_UNKNOWN self._use_variables = use_variables + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" # Load data - track_time_interval(hass, self._update_hub, SCAN_INTERVAL_HUB) - self._update_hub(None) + async_track_time_interval( + self.hass, self._update_hub, SCAN_INTERVAL_HUB) + yield from self.hass.async_add_job(self._update_hub, None) if self._use_variables: - track_time_interval( - hass, self._update_variables, SCAN_INTERVAL_VARIABLES) - self._update_variables(None) + async_track_time_interval( + self.hass, self._update_variables, SCAN_INTERVAL_VARIABLES) + yield from self.hass.async_add_job(self._update_variables, None) @property def name(self): @@ -624,7 +622,9 @@ class HMHub(Entity): """Retrieve latest state.""" state = self._homematic.getServiceMessages(self._name) self._state = STATE_UNKNOWN if state is None else len(state) - self.schedule_update_ha_state() + + if now: + self.schedule_update_ha_state() def _update_variables(self, now): """Retrive all variable data and update hmvariable states.""" @@ -640,7 +640,7 @@ class HMHub(Entity): state_change = True self._variables.update({key: value}) - if state_change: + if state_change and now: self.schedule_update_ha_state() def hm_set_variable(self, name, value): @@ -662,16 +662,15 @@ class HMHub(Entity): class HMDevice(Entity): """The HomeMatic device base object.""" - def __init__(self, hass, config): + def __init__(self, config): """Initialize a generic HomeMatic device.""" - self.hass = hass - self._homematic = hass.data[DATA_HOMEMATIC] self._name = config.get(ATTR_NAME) self._address = config.get(ATTR_ADDRESS) self._proxy = config.get(ATTR_PROXY) self._channel = config.get(ATTR_CHANNEL) self._state = config.get(ATTR_PARAM) self._data = {} + self._homematic = None self._hmdevice = None self._connected = False self._available = False @@ -680,6 +679,11 @@ class HMDevice(Entity): if self._state: self._state = self._state.upper() + @asyncio.coroutine + def async_added_to_hass(self): + """Load data init callbacks.""" + yield from self.hass.async_add_job(self.link_homematic) + @property def should_poll(self): """Return false. HomeMatic states are pushed by the XML-RPC Server.""" @@ -728,16 +732,13 @@ class HMDevice(Entity): return True # Initialize + self._homematic = self.hass.data[DATA_HOMEMATIC] self._hmdevice = self._homematic.devices[self._proxy][self._address] self._connected = True try: # Initialize datapoints of this object self._init_data() - if self.hass.data[DATA_DELAY]: - # We optionally delay / pause loading of data to avoid - # overloading of CCU / Homegear - time.sleep(self.hass.data[DATA_DELAY]) self._load_data_from_hm() # Link events from pyhomematic diff --git a/homeassistant/components/image_processing/dlib_face_detect.py b/homeassistant/components/image_processing/dlib_face_detect.py index 6de85f022f3..65705feb7f7 100644 --- a/homeassistant/components/image_processing/dlib_face_detect.py +++ b/homeassistant/components/image_processing/dlib_face_detect.py @@ -15,7 +15,7 @@ from homeassistant.components.image_processing import ( from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index 50a7bc846c4..22594aa2547 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing.microsoft_face_identify import ( ImageProcessingFaceEntity) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['face_recognition==0.2.2'] +REQUIREMENTS = ['face_recognition==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py new file mode 100755 index 00000000000..583181fe453 --- /dev/null +++ b/homeassistant/components/input_text.py @@ -0,0 +1,191 @@ +""" +Component to offer a way to enter a value into a text box. + +For more details about this component, please refer to the documentation +at https://home-assistant.io/components/input_text/ +""" +import asyncio +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME) +from homeassistant.loader import bind_hass +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import async_get_last_state + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'input_text' +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +CONF_INITIAL = 'initial' +CONF_MIN = 'min' +CONF_MAX = 'max' + +ATTR_VALUE = 'value' +ATTR_MIN = 'min' +ATTR_MAX = 'max' +ATTR_PATTERN = 'pattern' + +SERVICE_SET_VALUE = 'set_value' + +SERVICE_SET_VALUE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_VALUE): cv.string, +}) + + +def _cv_input_text(cfg): + """Configure validation helper for input box (voluptuous).""" + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + if minimum > maximum: + raise vol.Invalid('Max len ({}) is not greater than min len ({})' + .format(minimum, maximum)) + state = cfg.get(CONF_INITIAL) + if state is not None and (len(state) < minimum or len(state) > maximum): + raise vol.Invalid('Initial value {} length not in range {}-{}' + .format(state, minimum, maximum)) + return cfg + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: vol.All({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=0): vol.Coerce(int), + vol.Optional(CONF_MAX, default=100): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ''): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(ATTR_PATTERN): cv.string, + }, _cv_input_text) + }) +}, required=True, extra=vol.ALLOW_EXTRA) + + +@bind_hass +def set_value(hass, entity_id, value): + """Set input_text to value.""" + hass.services.call(DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + }) + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up an input text box.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + entities = [] + + for object_id, cfg in config[DOMAIN].items(): + name = cfg.get(CONF_NAME) + minimum = cfg.get(CONF_MIN) + maximum = cfg.get(CONF_MAX) + initial = cfg.get(CONF_INITIAL) + icon = cfg.get(CONF_ICON) + unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT) + pattern = cfg.get(ATTR_PATTERN) + + entities.append(InputText( + object_id, name, initial, minimum, maximum, icon, unit, + pattern)) + + if not entities: + return False + + @asyncio.coroutine + def async_set_value_service(call): + """Handle a calls to the input box services.""" + target_inputs = component.async_extract_from_service(call) + + tasks = [input_text.async_set_value(call.data[ATTR_VALUE]) + for input_text in target_inputs] + if tasks: + yield from asyncio.wait(tasks, loop=hass.loop) + + hass.services.async_register( + DOMAIN, SERVICE_SET_VALUE, async_set_value_service, + schema=SERVICE_SET_VALUE_SCHEMA) + + yield from component.async_add_entities(entities) + return True + + +class InputText(Entity): + """Represent a text box.""" + + def __init__(self, object_id, name, initial, minimum, maximum, icon, + unit, pattern): + """Initialize a text input.""" + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._name = name + self._current_value = initial + self._minimum = minimum + self._maximum = maximum + self._icon = icon + self._unit = unit + self._pattern = pattern + + @property + def should_poll(self): + """If entity should be polled.""" + return False + + @property + def name(self): + """Return the name of the text input entity.""" + return self._name + + @property + def icon(self): + """Return the icon to be used for this entity.""" + return self._icon + + @property + def state(self): + """Return the state of the component.""" + return self._current_value + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def state_attributes(self): + """Return the state attributes.""" + return { + ATTR_MIN: self._minimum, + ATTR_MAX: self._maximum, + ATTR_PATTERN: self._pattern, + } + + @asyncio.coroutine + def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + if self._current_value is not None: + return + + state = yield from async_get_last_state(self.hass, self.entity_id) + value = state and state.state + + # Check against None because value can be 0 + if value is not None and self._minimum <= len(value) <= self._maximum: + self._current_value = value + + @asyncio.coroutine + def async_set_value(self, value): + """Select new value.""" + if len(value) < self._minimum or len(value) > self._maximum: + _LOGGER.warning("Invalid value: %s (length range %s - %s)", + value, self._minimum, self._maximum) + return + self._current_value = value + yield from self.async_update_ha_state() diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 92807bf9b1c..94b70e47cba 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -102,7 +102,7 @@ def common_attributes(entity): 'address': 'INSTEON Address', 'description': 'Description', 'model': 'Model', - 'cat': 'Cagegory', + 'cat': 'Category', 'subcat': 'Subcategory', 'firmware': 'Firmware', 'product_key': 'Product Key' diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index a834cc0a3e4..7686eb7dc7d 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -17,7 +17,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.7'] +REQUIREMENTS = ['PyISY==1.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 9530becb6ce..a5015ff9454 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -1,495 +1,255 @@ """ -Support for KNX components. -For more details about this component, please refer to the documentation at +Connects to KNX platform. + +For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ + """ import logging -import os +import asyncio import voluptuous as vol +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT) -from homeassistant.helpers.entity import Entity -from homeassistant.config import load_yaml_config_file +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, \ + CONF_HOST, CONF_PORT +from homeassistant.helpers.script import Script -REQUIREMENTS = ['knxip==0.5'] +DOMAIN = "knx" +DATA_KNX = "data_knx" +CONF_KNX_CONFIG = "config_file" + +CONF_KNX_ROUTING = "routing" +CONF_KNX_TUNNELING = "tunneling" +CONF_KNX_LOCAL_IP = "local_ip" +CONF_KNX_FIRE_EVENT = "fire_event" +CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" + +SERVICE_KNX_SEND = "send" +SERVICE_KNX_ATTR_ADDRESS = "address" +SERVICE_KNX_ATTR_PAYLOAD = "payload" + +ATTR_DISCOVER_DEVICES = 'devices' _LOGGER = logging.getLogger(__name__) -DEFAULT_HOST = '0.0.0.0' -DEFAULT_PORT = 3671 -DOMAIN = 'knx' +REQUIREMENTS = ['xknx==0.7.13'] -EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' -EVENT_KNX_FRAME_SEND = 'knx_frame_send' +TUNNELING_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) -KNXTUNNEL = None -KNX_ADDRESS = "address" -KNX_DATA = "data" -KNX_GROUP_WRITE = "group_write" -CONF_LISTEN = "listen" +ROUTING_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_LOCAL_IP): cv.string, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_LISTEN, default=[]): - vol.All(cv.ensure_list, [cv.string]), - }), + vol.Optional(CONF_KNX_CONFIG): cv.string, + vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA, + vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'): + TUNNELING_SCHEMA, + vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'): + cv.boolean, + vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): + vol.All( + cv.ensure_list, + [cv.string]) + }) }, extra=vol.ALLOW_EXTRA) -KNX_WRITE_SCHEMA = vol.Schema({ - vol.Required(KNX_ADDRESS): vol.All(cv.ensure_list, [cv.string]), - vol.Required(KNX_DATA): vol.All(cv.ensure_list, [cv.byte]) +SERVICE_KNX_SEND_SCHEMA = vol.Schema({ + vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string, + vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any( + cv.positive_int, [cv.positive_int]), }) -def setup(hass, config): - """Set up the connection to the KNX IP interface.""" - global KNXTUNNEL - - from knxip.ip import KNXIPTunnel - from knxip.core import KNXException, parse_group_address - - host = config[DOMAIN].get(CONF_HOST) - port = config[DOMAIN].get(CONF_PORT) - - if host == '0.0.0.0': - _LOGGER.debug("Will try to auto-detect KNX/IP gateway") - - KNXTUNNEL = KNXIPTunnel(host, port) +@asyncio.coroutine +def async_setup(hass, config): + """Set up knx component.""" + from xknx.exceptions import XKNXException try: - res = KNXTUNNEL.connect() - _LOGGER.debug("Res = %s", res) - if not res: - _LOGGER.error("Could not connect to KNX/IP interface %s", host) - return False + hass.data[DATA_KNX] = KNXModule(hass, config) + yield from hass.data[DATA_KNX].start() - except KNXException as ex: - _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex) - KNXTUNNEL = None + except XKNXException as ex: + _LOGGER.exception("Can't connect to KNX interface: %s", ex) return False - _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + for component, discovery_type in ( + ('switch', 'Switch'), + ('climate', 'Climate'), + ('cover', 'Cover'), + ('light', 'Light'), + ('sensor', 'Sensor'), + ('binary_sensor', 'BinarySensor'), + ('notify', 'Notification')): + found_devices = _get_devices(hass, discovery_type) + hass.async_add_job( + discovery.async_load_platform(hass, component, DOMAIN, { + ATTR_DISCOVER_DEVICES: found_devices + }, config)) - descriptions = load_yaml_config_file( - os.path.join(os.path.dirname(__file__), 'services.yaml')) - - def received_knx_event(address, data): - """Process received KNX message.""" - if len(data) == 1: - data = data[0] - hass.bus.fire('knx_event', { - 'address': address, - 'data': data - }) - - for listen in config[DOMAIN].get(CONF_LISTEN): - _LOGGER.debug("Registering listener for %s", listen) - try: - KNXTUNNEL.register_listener(parse_group_address(listen), - received_knx_event) - except KNXException as knxexception: - _LOGGER.error("Can't register KNX listener for address %s (%s)", - listen, knxexception) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) - - # Listen to KNX events and send them to the bus - def handle_group_write(call): - """Bridge knx_frame_send events to the KNX bus.""" - # parameters are pre-validated using KNX_WRITE_SCHEMA - addrlist = call.data.get("address") - knxdata = call.data.get("data") - - knxaddrlist = [] - for addr in addrlist: - try: - _LOGGER.debug("Found %s", addr) - knxaddr = int(addr) - except ValueError: - knxaddr = None - - if knxaddr is None: - try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect: %s", addr) - - knxaddrlist.append(knxaddr) - - for addr in knxaddrlist: - KNXTUNNEL.group_write(addr, knxdata) - - # Listen for when knx_frame_send event is fired - hass.services.register(DOMAIN, - KNX_GROUP_WRITE, - handle_group_write, - descriptions[DOMAIN][KNX_GROUP_WRITE], - schema=KNX_WRITE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_KNX_SEND, + hass.data[DATA_KNX].service_send_to_knx_bus, + schema=SERVICE_KNX_SEND_SCHEMA) return True -def close_tunnel(_data): - """Close the NKX tunnel connection on shutdown.""" - global KNXTUNNEL - - KNXTUNNEL.disconnect() - KNXTUNNEL = None +def _get_devices(hass, discovery_type): + return list( + map(lambda device: device.name, + filter( + lambda device: type(device).__name__ == discovery_type, + hass.data[DATA_KNX].xknx.devices))) -class KNXConfig(object): - """Handle the fetching of configuration from the config file.""" - - def __init__(self, config): - """Initialize the configuration.""" - from knxip.core import parse_group_address - - self.config = config - self.should_poll = config.get('poll', True) - if config.get('address'): - self._address = parse_group_address(config.get('address')) - else: - self._address = None - if self.config.get('state_address'): - self._state_address = parse_group_address( - self.config.get('state_address')) - else: - self._state_address = None - - @property - def name(self): - """Return the name given to the entity.""" - return self.config['name'] - - @property - def address(self): - """Return the address of the device as an integer value. - - 3 types of addresses are supported: - integer - 0-65535 - 2 level - a/b - 3 level - a/b/c - """ - return self._address - - @property - def state_address(self): - """Return the group address the device sends its current state to. - - Some KNX devices can send the current state to a seperate - group address. This makes send e.g. when an actuator can - be switched but also have a timer functionality. - """ - return self._state_address - - -class KNXGroupAddress(Entity): - """Representation of devices connected to a KNX group address.""" +class KNXModule(object): + """Representation of KNX Object.""" def __init__(self, hass, config): - """Initialize the device.""" - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "Initalizing KNX group address for %s (%s)", - self.name, self.address - ) + """Initialization of KNXModule.""" + self.hass = hass + self.config = config + self.initialized = False + self.init_xknx() + self.register_callbacks() - def handle_knx_message(addr, data): - """Handle an incoming KNX frame. + def init_xknx(self): + """Initialization of KNX object.""" + from xknx import XKNX + self.xknx = XKNX( + config=self.config_file(), + loop=self.hass.loop) - Handle an incoming frame and update our status if it contains - information relating to this device. - """ - if (addr == self.state_address) or (addr == self.address): - self._state = data[0] - self.schedule_update_ha_state() + @asyncio.coroutine + def start(self): + """Start KNX object. Connect to tunneling or Routing device.""" + connection_config = self.connection_config() + yield from self.xknx.start( + state_updater=True, + connection_config=connection_config) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + self.initialized = True - KNXTUNNEL.register_listener(self.address, handle_knx_message) - if self.state_address: - KNXTUNNEL.register_listener(self.state_address, handle_knx_message) + @asyncio.coroutine + def stop(self, event): + """Stop KNX object. Disconnect from tunneling or Routing device.""" + yield from self.xknx.stop() - @property - def name(self): - """Return the entity's display name.""" - return self._config.name + def config_file(self): + """Resolve and return the full path of xknx.yaml if configured.""" + config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG) + if not config_file: + return None + if not config_file.startswith("/"): + return self.hass.config.path(config_file) + return config_file - @property - def config(self): - """Return the entity's configuration.""" - return self._config + def connection_config(self): + """Return the connection_config.""" + if CONF_KNX_TUNNELING in self.config[DOMAIN]: + return self.connection_config_tunneling() + elif CONF_KNX_ROUTING in self.config[DOMAIN]: + return self.connection_config_routing() + return self.connection_config_auto() - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll + def connection_config_routing(self): + """Return the connection_config if routing is configured.""" + from xknx.io import ConnectionConfig, ConnectionType + local_ip = \ + self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP) + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + local_ip=local_ip) - @property - def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + def connection_config_tunneling(self): + """Return the connection_config if tunneling is configured.""" + from xknx.io import ConnectionConfig, ConnectionType, \ + DEFAULT_MCAST_PORT + gateway_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST) + gateway_port = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT) + local_ip = \ + self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP) + if gateway_port is None: + gateway_port = DEFAULT_MCAST_PORT + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=gateway_ip, + gateway_port=gateway_port, + local_ip=local_ip) - @property - def address(self): - """Return the KNX group address.""" - return self._config.address + def connection_config_auto(self): + """Return the connection_config if auto is configured.""" + # pylint: disable=no-self-use + from xknx.io import ConnectionConfig + return ConnectionConfig() - @property - def state_address(self): - """Return the KNX group address.""" - return self._config.state_address + def register_callbacks(self): + """Register callbacks within XKNX object.""" + if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \ + self.config[DOMAIN][CONF_KNX_FIRE_EVENT]: + from xknx.knx import AddressFilter + address_filters = list(map( + AddressFilter, + self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER])) + self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, address_filters) - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def group_write(self, value): - """Write to the group address.""" - KNXTUNNEL.group_write(self.address, [value]) - - def update(self): - """Get the state from KNX bus or cache.""" - from knxip.core import KNXException - - try: - if self.state_address: - res = KNXTUNNEL.group_read( - self.state_address, use_cache=self.cache) - else: - res = KNXTUNNEL.group_read(self.address, use_cache=self.cache) - - if res: - self._state = res[0] - self._data = res - else: - _LOGGER.debug( - "%s: unable to read from KNX address: %s (None)", - self.name, self.address - ) - - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, self.address - ) - return False - - -class KNXMultiAddressDevice(Entity): - """Representation of devices connected to a multiple KNX group address. - - This is needed for devices like dimmers or shutter actuators as they have - to be controlled by multiple group addresses. - """ - - def __init__(self, hass, config, required, optional=None): - """Initialize the device. - - The namelist argument lists the required addresses. E.g. for a dimming - actuators, the namelist might look like: - onoff_address: 0/0/1 - brightness_address: 0/0/2 - """ - from knxip.core import parse_group_address, KNXException - - self.names = {} - self.values = {} - - self._config = config - self._state = False - self._data = None - _LOGGER.debug( - "%s: initalizing KNX multi address device", - self.name - ) - - settings = self._config.config - if config.address: - _LOGGER.debug( - "%s: base address: address=%s", - self.name, settings.get('address') - ) - self.names[config.address] = 'base' - if config.state_address: - _LOGGER.debug( - "%s, state address: state_address=%s", - self.name, settings.get('state_address') - ) - self.names[config.state_address] = 'state' - - # parse required addresses - for name in required: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - if addr is None: - _LOGGER.error( - "%s: Required KNX group address %s missing", - self.name, paramname - ) - raise KNXException( - "%s: Group address for {} missing in " - "configuration for {}".format( - self.name, paramname - ) - ) - _LOGGER.debug( - "%s: (required parameter) %s=%s", - self.name, paramname, addr - ) - addr = parse_group_address(addr) - self.names[addr] = name - - # parse optional addresses - for name in optional: - paramname = '{}{}'.format(name, '_address') - addr = settings.get(paramname) - _LOGGER.debug( - "%s: (optional parameter) %s=%s", - self.name, paramname, addr - ) - if addr: - try: - addr = parse_group_address(addr) - except KNXException: - _LOGGER.exception( - "%s: cannot parse group address %s", - self.name, addr - ) - self.names[addr] = name - - @property - def name(self): - """Return the entity's display name.""" - return self._config.name - - @property - def config(self): - """Return the entity's configuration.""" - return self._config - - @property - def should_poll(self): - """Return the state of the polling, if needed.""" - return self._config.should_poll - - @property - def cache(self): - """Return the name given to the entity.""" - return self._config.config.get('cache', True) - - def has_attribute(self, name): - """Check if the attribute with the given name is defined. - - This is mostly important for optional addresses. - """ - for attributename in self.names.values(): - if attributename == name: - return True + @asyncio.coroutine + def telegram_received_cb(self, telegram): + """Callback invoked after a KNX telegram was received.""" + self.hass.bus.fire('knx_event', { + 'address': telegram.group_address.str(), + 'data': telegram.payload.value + }) + # False signals XKNX to proceed with processing telegrams. return False - def set_percentage(self, name, percentage): - """Set a percentage in knx for a given attribute. + @asyncio.coroutine + def service_send_to_knx_bus(self, call): + """Service for sending an arbitray KNX message to the KNX bus.""" + from xknx.knx import Telegram, Address, DPTBinary, DPTArray + attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) + attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - percentage = abs(percentage) # only accept positive values - scaled_value = percentage * 255 / 100 - value = min(255, scaled_value) - return self.set_int_value(name, value) + def calculate_payload(attr_payload): + """Calculate payload depending on type of attribute.""" + if isinstance(attr_payload, int): + return DPTBinary(attr_payload) + return DPTArray(attr_payload) + payload = calculate_payload(attr_payload) + address = Address(attr_address) - def get_percentage(self, name): - """Get a percentage from knx for a given attribute. + telegram = Telegram() + telegram.payload = payload + telegram.group_address = address + yield from self.xknx.telegrams.put(telegram) - DPT_Scaling / DPT 5.001 is a single byte scaled percentage - """ - value = self.get_int_value(name) - percentage = round(value * 100 / 255) - return percentage - def set_int_value(self, name, value, num_bytes=1): - """Set an integer value for a given attribute.""" - # KNX packets are big endian - value = round(value) # only accept integers - b_value = value.to_bytes(num_bytes, byteorder='big') - return self.set_value(name, list(b_value)) +class KNXAutomation(): + """Wrapper around xknx.devices.ActionCallback object..""" - def get_int_value(self, name): - """Get an integer value for a given attribute.""" - # KNX packets are big endian - summed_value = 0 - raw_value = self.value(name) - try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: - # pknx returns a non-iterable type for unsuccessful reads - pass + def __init__(self, hass, device, hook, action, counter=1): + """Initialize Automation class.""" + self.hass = hass + self.device = device + script_name = "{} turn ON script".format(device.get_name()) + self.script = Script(hass, action, script_name) - return summed_value - - def value(self, name): - """Return the value to a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - res = KNXTUNNEL.group_read(addr, use_cache=self.cache) - except KNXException: - _LOGGER.exception( - "%s: unable to read from KNX address: %s", - self.name, addr - ) - return False - - return res - - def set_value(self, name, value): - """Set the value of a given named attribute.""" - from knxip.core import KNXException - - addr = None - for attributeaddress, attributename in self.names.items(): - if attributename == name: - addr = attributeaddress - - if addr is None: - _LOGGER.error("%s: attribute '%s' undefined", - self.name, name) - _LOGGER.debug( - "%s: defined attributes: %s", - self.name, str(self.names) - ) - return False - - try: - KNXTUNNEL.group_write(addr, value) - except KNXException: - _LOGGER.exception( - "%s: unable to write to KNX address: %s", - self.name, addr - ) - return False - - return True + import xknx + self.action = xknx.devices.ActionCallback( + hass.data[DATA_KNX].xknx, + self.script.async_run, + hook=hook, + counter=counter) + device.actions.append(self.action) diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 60865dd223e..807c19fffdb 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -24,8 +24,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMLight(hass, conf) - new_device.link_homematic() + new_device = HMLight(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 746c6489c9e..79d80d2b8a0 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -83,6 +83,7 @@ SCENE_SCHEMA = vol.Schema({ }) ATTR_IS_HUE_GROUP = "is_hue_group" +GROUP_NAME_ALL_HUE_LIGHTS = "All Hue Lights" def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): @@ -203,6 +204,21 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, _LOGGER.error("Got unexpected result from Hue API") return + if not skip_groups: + # Group ID 0 is a special group in the hub for all lights, but it + # is not returned by get_api() so explicity get it and include it. + # See https://developers.meethue.com/documentation/ + # groups-api#21_get_all_groups + _LOGGER.debug("Getting group 0 from bridge") + all_lights = bridge.get_group(0) + if not isinstance(all_lights, dict): + _LOGGER.error("Got unexpected result from Hue API for group 0") + return + # Hue hub returns name of group 0 as "Group 0", so rename + # for ease of use in HA. + all_lights['name'] = GROUP_NAME_ALL_HUE_LIGHTS + api_groups["0"] = all_lights + new_lights = [] api_name = api.get('config').get('name') diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index d89d45e99a7..62261944feb 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -1,17 +1,17 @@ """ -Support KNX Lighting actuators. +Support for KNX/IP lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/Light.knx/ +https://home-assistant.io/components/light.knx/ """ -import logging +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice) -from homeassistant.components.light import (Light, PLATFORM_SCHEMA, - SUPPORT_BRIGHTNESS, - ATTR_BRIGHTNESS) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.light import PLATFORM_SCHEMA, Light, \ + SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -19,8 +19,6 @@ CONF_STATE_ADDRESS = 'state_address' CONF_BRIGHTNESS_ADDRESS = 'brightness_address' CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address' -_LOGGER = logging.getLogger(__name__) - DEFAULT_NAME = 'KNX Light' DEPENDENCIES = ['knx'] @@ -33,84 +31,136 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX light platform.""" - add_devices([KNXLight(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up light(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXLight(KNXMultiAddressDevice, Light): - """Representation of a KNX Light device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up lights for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXLight(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize the cover.""" - KNXMultiAddressDevice.__init__( - self, hass, config, - [], # required - optional=['state', 'brightness', 'brightness_state'] - ) - self._hass = hass - self._supported_features = 0 - if CONF_BRIGHTNESS_ADDRESS in config.config: - _LOGGER.debug("%s is dimmable", self.name) - self._supported_features = self._supported_features | \ - SUPPORT_BRIGHTNESS - self._brightness = None +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up light for KNX platform configured within plattform.""" + import xknx + light = xknx.devices.Light( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address_switch=config.get(CONF_ADDRESS), + group_address_switch_state=config.get(CONF_STATE_ADDRESS), + group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS), + group_address_brightness_state=config.get( + CONF_BRIGHTNESS_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(light) + add_devices([KNXLight(hass, light)]) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn on", self.name) - self.set_value('base', [1]) - self._state = 1 +class KNXLight(Light): + """Representation of a KNX light.""" - if ATTR_BRIGHTNESS in kwargs: - self._brightness = kwargs[ATTR_BRIGHTNESS] - _LOGGER.debug("turn_on requested brightness for light: %s is: %s ", - self.name, self._brightness) - assert self._brightness <= 255 - self.set_value("brightness", [self._brightness]) + def __init__(self, hass, device): + """Initialization of KNXLight.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - if not self.should_poll: - self.schedule_update_ha_state() + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) - def turn_off(self, **kwargs): - """Turn the switch off. + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name - This sends a value 1 to the group address of the device - """ - _LOGGER.debug("%s: turn off", self.name) - self.set_value('base', [0]) - self._state = 0 - if not self.should_poll: - self.schedule_update_ha_state() + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self.device.brightness \ + if self.device.supports_dimming else \ + None + + @property + def xy_color(self): + """Return the XY color value [float, float].""" + return None + + @property + def rgb_color(self): + """Return the RBG color value.""" + return None + + @property + def color_temp(self): + """Return the CT color temperature.""" + return None + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return None + + @property + def effect_list(self): + """Return the list of supported effects.""" + return None + + @property + def effect(self): + """Return the current effect.""" + return None @property def is_on(self): - """Return True if the value is not 0 is on, else False.""" - return self._state != 0 + """Return true if light is on.""" + return self.device.state @property def supported_features(self): """Flag supported features.""" - return self._supported_features + flags = 0 + if self.device.supports_dimming: + flags |= SUPPORT_BRIGHTNESS + return flags - def update(self): - """Update device state.""" - super().update() - if self.has_attribute('brightness_state'): - value = self.value('brightness_state') - if value is not None: - self._brightness = int.from_bytes(value, byteorder='little') - _LOGGER.debug("%s: brightness = %d", - self.name, self._brightness) + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: + yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + else: + yield from self.device.set_on() - if self.has_attribute('state'): - self._state = self.value("state")[0] - _LOGGER.debug("%s: state = %d", self.name, self._state) - - def should_poll(self): - """No polling needed for a KNX light.""" - return False + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index 8e4e9d7450e..c11b3da6f75 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/light.lutron_caseta/ import logging from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, DOMAIN) from homeassistant.components.light.lutron import ( to_hass_level, to_lutron_level) from homeassistant.components.lutron_caseta import ( @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - light_devices = bridge.get_devices_by_types(["WallDimmer", "PlugInDimmer"]) + light_devices = bridge.get_devices_by_domain(DOMAIN) for light_device in light_devices: dev = LutronCasetaLight(light_device, bridge) devs.append(dev) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index 038cacd300e..ac72a7052f1 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -39,6 +39,7 @@ CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic' CONF_EFFECT_LIST = 'effect_list' CONF_EFFECT_STATE_TOPIC = 'effect_state_topic' CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template' +CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template' CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic' CONF_RGB_STATE_TOPIC = 'rgb_state_topic' CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template' @@ -75,6 +76,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, @@ -125,6 +127,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE), + CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE), CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), @@ -397,10 +400,17 @@ class MqttLight(Light): if ATTR_RGB_COLOR in kwargs and \ self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] + if tpl: + colors = {'red', 'green', 'blue'} + variables = {key: val for key, val in + zip(colors, kwargs[ATTR_RGB_COLOR])} + rgb_color_str = tpl.async_render(variables) + else: + rgb_color_str = '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]), self._qos, - self._retain) + rgb_color_str, self._qos, self._retain) if self._optimistic_rgb: self._rgb = kwargs[ATTR_RGB_COLOR] diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index f831d6c04ce..9248b0131f1 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RFXtrx platform.""" import RFXtrx as rfxtrxmod - lights = rfxtrx.get_devices_from_config(config, RfxtrxLight, hass) + lights = rfxtrx.get_devices_from_config(config, RfxtrxLight) add_devices(lights) def light_update(event): @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): not event.device.known_to_be_dimmable: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxLight, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxLight) if new_device: add_devices([new_device]) diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index b04640d7a8a..fa21af996cb 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -9,9 +9,10 @@ import logging from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light) -from homeassistant.components.light import \ - PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA -from homeassistant.components.tradfri import KEY_GATEWAY, KEY_TRADFRI_GROUPS +from homeassistant.components.light import ( + PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA) +from homeassistant.components.tradfri import ( + KEY_GATEWAY, KEY_TRADFRI_GROUPS, KEY_API) from homeassistant.util import color as color_util _LOGGER = logging.getLogger(__name__) @@ -19,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' -ALLOWED_TEMPERATURES = { - IKEA: {2200: 'efd275', 2700: 'f1e0b5', 4000: 'f5faf6'} -} +ALLOWED_TEMPERATURES = {IKEA} def setup_platform(hass, config, add_devices, discovery_info=None): @@ -30,24 +29,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return gateway_id = discovery_info['gateway'] + api = hass.data[KEY_API][gateway_id] gateway = hass.data[KEY_GATEWAY][gateway_id] - devices = gateway.get_devices() - lights = [dev for dev in devices if dev.has_light_control] - add_devices(Tradfri(light) for light in lights) + devices = api(gateway.get_devices()) + lights = [dev for dev in devices if api(dev).has_light_control] + add_devices(Tradfri(light, api) for light in lights) allow_tradfri_groups = hass.data[KEY_TRADFRI_GROUPS][gateway_id] if allow_tradfri_groups: - groups = gateway.get_groups() - add_devices(TradfriGroup(group) for group in groups) + groups = api(gateway.get_groups()) + add_devices(TradfriGroup(group, api) for group in groups) class TradfriGroup(Light): """The platform class required by hass.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Group.""" - self._group = light - self._name = light.name + self._group = api(light) + self._api = api + self._name = self._group.name @property def supported_features(self): @@ -71,20 +72,20 @@ class TradfriGroup(Light): def turn_off(self, **kwargs): """Instruct the group lights to turn off.""" - self._group.set_state(0) + self._api(self._group.set_state(0)) def turn_on(self, **kwargs): """Instruct the group lights to turn on, or dim.""" if ATTR_BRIGHTNESS in kwargs: - self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._group.set_state(1) + self._api(self._group.set_state(1)) def update(self): """Fetch new state data for this group.""" from pytradfri import RequestTimeout try: - self._group.update() + self._api(self._group.update()) except RequestTimeout: _LOGGER.warning("Tradfri update request timed out") @@ -92,14 +93,15 @@ class TradfriGroup(Light): class Tradfri(Light): """The platform class required by Home Asisstant.""" - def __init__(self, light): + def __init__(self, light, api): """Initialize a Light.""" - self._light = light + self._light = api(light) + self._api = api # Caching of LightControl and light object - self._light_control = light.light_control - self._light_data = light.light_control.lights[0] - self._name = light.name + self._light_control = self._light.light_control + self._light_data = self._light_control.lights[0] + self._name = self._light.name self._rgb_color = None self._features = SUPPORT_BRIGHTNESS @@ -109,8 +111,20 @@ class Tradfri(Light): else: self._features |= SUPPORT_RGB_COLOR - self._ok_temps = ALLOWED_TEMPERATURES.get( - self._light.device_info.manufacturer) + self._ok_temps = \ + self._light.device_info.manufacturer in ALLOWED_TEMPERATURES + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + from pytradfri.color import MAX_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MAX_KELVIN_WS) + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + from pytradfri.color import MIN_KELVIN_WS + return color_util.color_temperature_kelvin_to_mired(MIN_KELVIN_WS) @property def supported_features(self): @@ -135,20 +149,13 @@ class Tradfri(Light): @property def color_temp(self): """Return the CT color value in mireds.""" - if (self._light_data.hex_color is None or + if (self._light_data.kelvin_color is None or self.supported_features & SUPPORT_COLOR_TEMP == 0 or not self._ok_temps): return None - - kelvin = next(( - kelvin for kelvin, hex_color in self._ok_temps.items() - if hex_color == self._light_data.hex_color), None) - if kelvin is None: - _LOGGER.error( - "Unexpected color temperature found for %s: %s", - self.name, self._light_data.hex_color) - return - return color_util.color_temperature_kelvin_to_mired(kelvin) + return color_util.color_temperature_kelvin_to_mired( + self._light_data.kelvin_color + ) @property def rgb_color(self): @@ -157,7 +164,7 @@ class Tradfri(Light): def turn_off(self, **kwargs): """Instruct the light to turn off.""" - self._light_control.set_state(False) + self._api(self._light_control.set_state(False)) def turn_on(self, **kwargs): """ @@ -167,29 +174,27 @@ class Tradfri(Light): for ATTR_RGB_COLOR, this also supports Philips Hue bulbs. """ if ATTR_BRIGHTNESS in kwargs: - self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS]) + self._api(self._light_control.set_dimmer(kwargs[ATTR_BRIGHTNESS])) else: - self._light_control.set_state(True) + self._api(self._light_control.set_state(True)) if ATTR_RGB_COLOR in kwargs and self._light_data.hex_color is not None: - self._light.light_control.set_hex_color( - color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])) + self._api(self._light.light_control.set_hex_color( + color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR]))) elif ATTR_COLOR_TEMP in kwargs and \ self._light_data.hex_color is not None and self._ok_temps: kelvin = color_util.color_temperature_mired_to_kelvin( kwargs[ATTR_COLOR_TEMP]) - # find closest allowed kelvin temp from user input - kelvin = min(self._ok_temps.keys(), key=lambda x: abs(x - kelvin)) - self._light_control.set_hex_color(self._ok_temps[kelvin]) + self._api(self._light_control.set_kelvin_color(kelvin)) def update(self): """Fetch new state data for this light.""" from pytradfri import RequestTimeout try: - self._light.update() - except RequestTimeout: - _LOGGER.warning("Tradfri update request timed out") + self._api(self._light.update()) + except RequestTimeout as exception: + _LOGGER.warning("Tradfri update request timed out: %s", exception) # Handle Hue lights paired with the gateway # hex_color is 0 when bulb is unreachable diff --git a/homeassistant/components/light/xiaomi_philipslight.py b/homeassistant/components/light/xiaomi_philipslight.py new file mode 100644 index 00000000000..8df25153a73 --- /dev/null +++ b/homeassistant/components/light/xiaomi_philipslight.py @@ -0,0 +1,227 @@ +""" +Support for Xiaomi Philips Lights (LED Ball & Ceil). + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.xiaomi_philipslight/ +""" +import asyncio +from functools import partial +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import ( + PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, + ATTR_COLOR_TEMP, SUPPORT_COLOR_TEMP, Light, ) + +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) +from homeassistant.exceptions import PlatformNotReady + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Xiaomi Philips Light' +PLATFORM = 'xiaomi_philipslight' +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +REQUIREMENTS = ['python-mirobo==0.1.3'] + +# The light does not accept cct values < 1 +CCT_MIN = 1 +CCT_MAX = 100 + +SUCCESS = ['ok'] +ATTR_MODEL = 'model' + + +# pylint: disable=unused-argument +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the light from config.""" + from mirobo import Ceil, DeviceException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + token = config.get(CONF_TOKEN) + + _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) + + try: + light = Ceil(host, token) + device_info = light.info() + _LOGGER.info("%s %s %s initialized", + device_info.raw['model'], + device_info.raw['fw_ver'], + device_info.raw['hw_ver']) + + philips_light = XiaomiPhilipsLight(name, light, device_info) + hass.data[PLATFORM][host] = philips_light + except DeviceException: + raise PlatformNotReady + + async_add_devices([philips_light], update_before_add=True) + + +class XiaomiPhilipsLight(Light): + """Representation of a Xiaomi Philips Light.""" + + def __init__(self, name, light, device_info): + """Initialize the light device.""" + self._name = name + self._device_info = device_info + + self._brightness = None + self._color_temp = None + + self._light = light + self._state = None + self._state_attrs = { + ATTR_MODEL: self._device_info.raw['model'], + } + + @property + def should_poll(self): + """Poll the light.""" + return True + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def available(self): + """Return true when state is known.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._state_attrs + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def color_temp(self): + """Return the color temperature.""" + return self._color_temp + + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 175 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 333 + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + + @asyncio.coroutine + def _try_command(self, mask_error, func, *args, **kwargs): + """Call a light command handling error messages.""" + from mirobo import DeviceException + try: + result = yield from self.hass.async_add_job( + partial(func, *args, **kwargs)) + + _LOGGER.debug("Response received from light: %s", result) + + return result == SUCCESS + except DeviceException as exc: + _LOGGER.error(mask_error, exc) + return False + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] + percent_brightness = int(100 * brightness / 255) + + _LOGGER.debug( + "Setting brightness: %s %s%%", + self.brightness, percent_brightness) + + result = yield from self._try_command( + "Setting brightness failed: %s", + self._light.set_bright, percent_brightness) + + if result: + self._brightness = brightness + + if ATTR_COLOR_TEMP in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP] + percent_color_temp = self.translate( + color_temp, self.max_mireds, + self.min_mireds, CCT_MIN, CCT_MAX) + + _LOGGER.debug( + "Setting color temperature: " + "%s mireds, %s%% cct", + color_temp, percent_color_temp) + + result = yield from self._try_command( + "Setting color temperature failed: %s cct", + self._light.set_cct, percent_color_temp) + + if result: + self._color_temp = color_temp + + result = yield from self._try_command( + "Turning the light on failed.", self._light.on) + + if result: + self._state = True + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the light off.""" + result = yield from self._try_command( + "Turning the light off failed.", self._light.off) + + if result: + self._state = True + + @asyncio.coroutine + def async_update(self): + """Fetch state from the device.""" + from mirobo import DeviceException + try: + state = yield from self.hass.async_add_job(self._light.status) + _LOGGER.debug("Got new state: %s", state.data) + + self._state = state.is_on + self._brightness = int(255 * 0.01 * state.bright) + self._color_temp = self.translate(state.cct, CCT_MIN, CCT_MAX, + self.max_mireds, + self.min_mireds) + + except DeviceException as ex: + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @staticmethod + def translate(value, left_min, left_max, right_min, right_max): + """Map a value from left span to right span.""" + left_span = left_max - left_min + right_span = right_max - right_min + value_scaled = float(value - left_min) / float(left_span) + return int(right_min + (value_scaled * right_span)) diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 2a3ce18d74e..e7ba394a977 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -27,8 +27,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): endpoint = discovery_info['endpoint'] try: - primaries = yield from endpoint.light_color['num_primaries'] - discovery_info['num_primaries'] = primaries + discovery_info['color_capabilities'] \ + = yield from endpoint.light_color['color_capabilities'] except (AttributeError, KeyError): pass @@ -54,11 +54,11 @@ class Light(zha.Entity, light.Light): self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 if zcl_clusters.lighting.Color.cluster_id in self._in_clusters: - # Not sure all color lights necessarily support this directly - # Should we emulate it? - self._supported_features |= light.SUPPORT_COLOR_TEMP - # Silly heuristic, not sure if it works widely - if kwargs.get('num_primaries', 1) >= 3: + color_capabilities = kwargs.get('color_capabilities', 0x10) + if color_capabilities & 0x10: + self._supported_features |= light.SUPPORT_COLOR_TEMP + + if color_capabilities & 0x08: self._supported_features |= light.SUPPORT_XY_COLOR self._supported_features |= light.SUPPORT_RGB_COLOR self._xy_color = (1.0, 1.0) diff --git a/homeassistant/components/lock/abode.py b/homeassistant/components/lock/abode.py new file mode 100644 index 00000000000..aad720e0d7d --- /dev/null +++ b/homeassistant/components/lock/abode.py @@ -0,0 +1,49 @@ +""" +This component provides HA lock support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.lock import LockDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode lock devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + sensors = [] + for sensor in abode.get_devices(type_filter=(CONST.DEVICE_DOOR_LOCK)): + sensors.append(AbodeLock(abode, sensor)) + + add_devices(sensors) + + +class AbodeLock(AbodeDevice, LockDevice): + """Representation of an Abode lock.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def lock(self, **kwargs): + """Lock the device.""" + self._device.lock() + + def unlock(self, **kwargs): + """Unlock the device.""" + self._device.unlock() + + @property + def is_locked(self): + """Return true if device is on.""" + return self._device.is_locked diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 47a8e3146aa..04030c92425 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5'] +REQUIREMENTS = ['pynello==1.5.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/tesla.py b/homeassistant/components/lock/tesla.py new file mode 100644 index 00000000000..3e93e4787a0 --- /dev/null +++ b/homeassistant/components/lock/tesla.py @@ -0,0 +1,57 @@ +""" +Support for Tesla door locks. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.tesla/ +""" +import logging + +from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla lock platform.""" + devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller']) + for device in hass.data[TESLA_DOMAIN]['devices']['lock']] + add_devices(devices, True) + + +class TeslaLock(TeslaDevice, LockDevice): + """Representation of a Tesla door lock.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the lock.""" + self._state = None + super().__init__(tesla_device, controller) + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def lock(self, **kwargs): + """Send the lock command.""" + _LOGGER.debug("Locking doors for: %s", self._name) + self.tesla_device.lock() + self._state = STATE_LOCKED + + def unlock(self, **kwargs): + """Send the unlock command.""" + _LOGGER.debug("Unlocking doors for: %s", self._name) + self.tesla_device.unlock() + self._state = STATE_UNLOCKED + + @property + def is_locked(self): + """Get whether the lock is in locked state.""" + return self._state == STATE_LOCKED + + def update(self): + """Updating state of the lock.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_LOCKED if self.tesla_device.is_locked() \ + else STATE_UNLOCKED diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index dcb3347e919..8660546c910 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.2.7'] +REQUIREMENTS = ['pylutron-caseta==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index f1d6139ffb1..1ecb09ac022 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -15,7 +15,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.8.18'] +REQUIREMENTS = ['youtube_dl==2017.9.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index 06f95a7d3a7..94339514712 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -17,15 +17,16 @@ from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, - CONF_NAME, STATE_ON, CONF_ZONE) + CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.5.2'] +REQUIREMENTS = ['denonavr==0.5.3'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' CONF_ZONES = 'zones' CONF_VALID_ZONES = ['Zone2', 'Zone3'] @@ -51,7 +52,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, vol.Optional(CONF_ZONES): - vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]) + vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) NewHost = namedtuple('NewHost', ['host', 'name']) @@ -69,8 +71,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if cache is None: cache = hass.data[KEY_DENON_CACHE] = set() - # Get config option for show_all_sources + # Get config option for show_all_sources and timeout show_all_sources = config.get(CONF_SHOW_ALL_SOURCES) + timeout = config.get(CONF_TIMEOUT) # Get config option for additional zones zones = config.get(CONF_ZONES) @@ -103,14 +106,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for d_receiver in d_receivers: host = d_receiver["host"] name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) + new_hosts.append( + NewHost(host=host, name=name)) for entry in new_hosts: # Check if host not in cache, append it and save for later # starting if entry.host not in cache: new_device = denonavr.DenonAVR( - entry.host, entry.name, show_all_sources, add_zones) + host=entry.host, name=entry.name, + show_all_inputs=show_all_sources, timeout=timeout, + add_zones=add_zones) for new_zone in new_device.zones.values(): receivers.append(DenonDevice(new_zone)) cache.add(host) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 599b8fbbd71..a334dc7caa4 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.directv/ """ import voluptuous as vol +import requests from homeassistant.components.media_player import ( MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, @@ -25,7 +26,7 @@ SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY -KNOWN_HOSTS = [] +DATA_DIRECTV = "data_directv" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -37,32 +38,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DirecTV platform.""" + known_devices = hass.data.get(DATA_DIRECTV) + if not known_devices: + known_devices = [] hosts = [] - if discovery_info: - host = discovery_info.get('host') - - if host in KNOWN_HOSTS: - return - - hosts.append([ - 'DirecTV_' + discovery_info.get('serial', ''), - host, DEFAULT_PORT - ]) - - elif CONF_HOST in config: + if CONF_HOST in config: hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) ]) + elif discovery_info: + host = discovery_info.get('host') + name = 'DirecTV_' + discovery_info.get('serial', '') + + # attempt to discover additional RVU units + try: + resp = requests.get( + 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() + if "locations" in resp: + for loc in resp["locations"]: + if("locationName" in loc and "clientAddr" in loc + and loc["clientAddr"] not in known_devices): + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) + + except requests.exceptions.RequestException: + # bail out and just go forward with uPnP data + if DEFAULT_DEVICE not in known_devices: + hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - KNOWN_HOSTS.append(host) + known_devices.append(host[-1]) add_devices(dtvs) + hass.data[DATA_DIRECTV] = known_devices return True diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 63d27299aa7..a5ef91ecc87 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -322,6 +322,7 @@ class SonosDevice(MediaPlayerDevice): self._media_title = None self._media_radio_show = None self._media_next_title = None + self._available = True self._support_previous_track = False self._support_next_track = False self._support_play = False @@ -386,6 +387,11 @@ class SonosDevice(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + def _is_available(self): try: sock = socket.create_connection( @@ -416,11 +422,11 @@ class SonosDevice(MediaPlayerDevice): self._player.get_sonos_favorites()['favorites'] if self._last_avtransport_event: - is_available = True + self._available = True else: - is_available = self._is_available() + self._available = self._is_available() - if not is_available: + if not self._available: self._player_volume = None self._player_volume_muted = None self._status = 'OFF' @@ -897,7 +903,8 @@ class SonosDevice(MediaPlayerDevice): src = fav.pop() self._source_name = src['title'] - if 'object.container.playlistContainer' in src['meta']: + if ('object.container.playlistContainer' in src['meta'] or + 'object.container.album.musicAlbum' in src['meta']): self._replace_queue_with_playlist(src) self._player.play_from_queue(0) else: diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 239b13a6292..734285d918a 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -148,6 +148,10 @@ class SpotifyMediaPlayer(MediaPlayerDevice): new_token = \ self._oauth.refresh_access_token( self._token_info['refresh_token']) + # skip when refresh failed + if new_token is None: + return + self._token_info = new_token token_refreshed = True if self._player is None or token_refreshed: @@ -158,6 +162,12 @@ class SpotifyMediaPlayer(MediaPlayerDevice): def update(self): """Update state and attributes.""" self.refresh_spotify_instance() + + # Don't true update when token is expired + if self._oauth.is_token_expired(self._token_info): + _LOGGER.warning("Spotify failed to update, token expired.") + return + # Available devices player_devices = self._player.devices() if player_devices is not None: diff --git a/homeassistant/components/media_player/yamaha_musiccast.py b/homeassistant/components/media_player/yamaha_musiccast.py new file mode 100644 index 00000000000..88d17b4d627 --- /dev/null +++ b/homeassistant/components/media_player/yamaha_musiccast.py @@ -0,0 +1,233 @@ +"""Example for configuration.yaml. + +media_player: + - platform: yamaha_musiccast + name: "Living Room" + host: 192.168.xxx.xx + port: 5005 + +""" + +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, + STATE_UNKNOWN, STATE_ON +) +from homeassistant.components.media_player import ( + MediaPlayerDevice, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PLAY, + SUPPORT_VOLUME_SET, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP +) +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_FEATURES = ( + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | + SUPPORT_SELECT_SOURCE +) + +REQUIREMENTS = ['pymusiccast==0.1.0'] + +DEFAULT_NAME = "Yamaha Receiver" +DEFAULT_PORT = 5005 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Yamaha MusicCast platform.""" + import pymusiccast + + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + + receiver = pymusiccast.McDevice(host, udp_port=port) + _LOGGER.debug("receiver: %s / Port: %d", receiver, port) + + add_devices([YamahaDevice(receiver, name)], True) + + +class YamahaDevice(MediaPlayerDevice): + """Representation of a Yamaha MusicCast device.""" + + def __init__(self, receiver, name): + """Initialize the Yamaha MusicCast device.""" + self._receiver = receiver + self._name = name + self.power = STATE_UNKNOWN + self.volume = 0 + self.volume_max = 0 + self.mute = False + self._source = None + self._source_list = [] + self.status = STATE_UNKNOWN + self.media_status = None + self._receiver.set_yamaha_device(self) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.power == STATE_ON and self.status is not STATE_UNKNOWN: + return self.status + return self.power + + @property + def should_poll(self): + """Push an update after each command.""" + return True + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.mute + + @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 SUPPORTED_FEATURES + + @property + def source(self): + """Return the current input source.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @source_list.setter + def source_list(self, value): + """Set source_list attribute.""" + self._source_list = value + + @property + def media_content_type(self): + """Return the media content type.""" + return MEDIA_TYPE_MUSIC + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self.media_status.media_duration \ + if self.media_status else None + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self.media_status.media_image_url \ + if self.media_status else None + + @property + def media_artist(self): + """Artist of current playing media, music track only.""" + return self.media_status.media_artist if self.media_status else None + + @property + def media_album(self): + """Album of current playing media, music track only.""" + return self.media_status.media_album if self.media_status else None + + @property + def media_track(self): + """Track number of current playing media, music track only.""" + return self.media_status.media_track if self.media_status else None + + @property + def media_title(self): + """Title of current playing media.""" + return self.media_status.media_title if self.media_status else None + + def update(self): + """Get the latest details from the device.""" + _LOGGER.debug("update: %s", self.entity_id) + + # call from constructor setup_platform() + if not self.entity_id: + _LOGGER.debug("First run") + self._receiver.update_status(push=False) + # call from regular polling + else: + # update_status_timer was set before + if self._receiver.update_status_timer: + _LOGGER.debug( + "is_alive: %s", + self._receiver.update_status_timer.is_alive()) + # e.g. computer was suspended, while hass was running + if not self._receiver.update_status_timer.is_alive(): + _LOGGER.debug("Reinitializing") + self._receiver.update_status() + + def turn_on(self): + """Turn on specified media player or all.""" + _LOGGER.debug("Turn device: on") + self._receiver.set_power(True) + + def turn_off(self): + """Turn off specified media player or all.""" + _LOGGER.debug("Turn device: off") + self._receiver.set_power(False) + + def media_play(self): + """Send the media player the command for play/pause.""" + _LOGGER.debug("Play") + self._receiver.set_playback("play") + + def media_pause(self): + """Send the media player the command for pause.""" + _LOGGER.debug("Pause") + self._receiver.set_playback("pause") + + def media_stop(self): + """Send the media player the stop command.""" + _LOGGER.debug("Stop") + self._receiver.set_playback("stop") + + def media_previous_track(self): + """Send the media player the command for prev track.""" + _LOGGER.debug("Previous") + self._receiver.set_playback("previous") + + def media_next_track(self): + """Send the media player the command for next track.""" + _LOGGER.debug("Next") + self._receiver.set_playback("next") + + def mute_volume(self, mute): + """Send mute command.""" + _LOGGER.debug("Mute volume: %s", mute) + self._receiver.set_mute(mute) + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + _LOGGER.debug("Volume level: %.2f / %d", + volume, volume * self.volume_max) + self._receiver.set_volume(volume * self.volume_max) + + def select_source(self, source): + """Send the media player the command to select input source.""" + _LOGGER.debug("select_source: %s", source) + self.status = STATE_UNKNOWN + self._receiver.set_input(source) diff --git a/homeassistant/components/mycroft.py b/homeassistant/components/mycroft.py new file mode 100644 index 00000000000..834572bc551 --- /dev/null +++ b/homeassistant/components/mycroft.py @@ -0,0 +1,35 @@ +""" +Support for Mycroft AI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mycroft +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['mycroftapi==2.0'] + +_LOGGER = logging.getLogger(__name__) + + +DOMAIN = 'mycroft' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Mycroft component.""" + hass.data[DOMAIN] = config[DOMAIN][CONF_HOST] + discovery.load_platform(hass, 'notify', DOMAIN, {}, config) + return True diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py index 63bd1f6faac..c37116fb32d 100644 --- a/homeassistant/components/mysensors.py +++ b/homeassistant/components/mysensors.py @@ -27,7 +27,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component from homeassistant.setup import setup_component -REQUIREMENTS = ['pymysensors==0.11.0'] +REQUIREMENTS = ['pymysensors==0.11.1'] _LOGGER = logging.getLogger(__name__) @@ -49,6 +49,9 @@ CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' CONF_VERSION = 'version' +CONF_NODES = 'nodes' +CONF_NODE_NAME = 'name' + DEFAULT_BAUD_RATE = 115200 DEFAULT_TCP_PORT = 5003 DEFAULT_VERSION = '1.4' @@ -132,6 +135,12 @@ def deprecated(key): return validator +NODE_SCHEMA = vol.Schema({ + cv.positive_int: { + vol.Required(CONF_NODE_NAME): cv.string + } +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { vol.Required(CONF_GATEWAYS): vol.All( @@ -151,6 +160,7 @@ CONFIG_SCHEMA = vol.Schema({ CONF_TOPIC_IN_PREFIX, default=''): valid_subscribe_topic, vol.Optional( CONF_TOPIC_OUT_PREFIX, default=''): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, }] ), vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, @@ -358,6 +368,7 @@ def setup(hass, config): device, persistence_file, baud_rate, tcp_port, in_prefix, out_prefix) if ready_gateway is not None: + ready_gateway.nodes_config = gway.get(CONF_NODES) gateways[id(ready_gateway)] = ready_gateway if not gateways: @@ -474,12 +485,14 @@ def gw_callback_factory(hass): validated = validate_child(msg.gateway, msg.node_id, child) for platform, dev_ids in validated.items(): devices = get_mysensors_devices(hass, platform) - for idx, dev_id in enumerate(list(dev_ids)): + new_dev_ids = [] + for dev_id in dev_ids: if dev_id in devices: - dev_ids.pop(idx) signals.append(SIGNAL_CALLBACK.format(*dev_id)) - if dev_ids: - discover_mysensors_platform(hass, platform, dev_ids) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform(hass, platform, new_dev_ids) for signal in set(signals): # Only one signal per device is needed. # A device can have multiple platforms, ie multiple schemas. @@ -495,8 +508,13 @@ def gw_callback_factory(hass): def get_mysensors_name(gateway, node_id, child_id): """Return a name for a node child.""" - return '{} {} {}'.format( - gateway.sensors[node_id].sketch_name, node_id, child_id) + node_name = '{} {}'.format( + gateway.sensors[node_id].sketch_name, node_id) + node_name = next( + (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() + if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), + node_name) + return '{} {}'.format(node_name, child_id) def get_mysensors_gateway(hass, gateway_id): diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 1c17d1a795a..9496ff1d596 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -82,8 +82,6 @@ def async_setup(hass, config): """Set up a notify platform.""" if p_config is None: p_config = {} - if discovery_info is None: - discovery_info = {} platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -105,8 +103,12 @@ def async_setup(hass, config): raise HomeAssistantError("Invalid notify platform.") if notify_service is None: - _LOGGER.error( - "Failed to initialize notification service %s", p_type) + # Platforms can decide not to create a service based + # on discovery data. + if discovery_info is None: + _LOGGER.error( + "Failed to initialize notification service %s", + p_type) return except Exception: # pylint: disable=broad-except @@ -115,6 +117,9 @@ def async_setup(hass, config): notify_service.hass = hass + if discovery_info is None: + discovery_info = {} + @asyncio.coroutine def async_notify_message(service): """Handle sending notification message service calls.""" diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index a4ce304167f..90212bca025 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -15,7 +15,7 @@ from homeassistant.components.notify import ( _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['discord.py==0.16.10'] +REQUIREMENTS = ['discord.py==0.16.11'] CONF_TOKEN = 'token' diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py new file mode 100644 index 00000000000..c5dbcb0d4ad --- /dev/null +++ b/homeassistant/components/notify/knx.py @@ -0,0 +1,99 @@ +""" +KNX/IP notification service. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/notify.knx/ +""" +import asyncio +import voluptuous as vol + +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.notify import PLATFORM_SCHEMA, \ + BaseNotificationService +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +DEFAULT_NAME = 'KNX Notify' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +@asyncio.coroutine +def async_get_service(hass, config, discovery_info=None): + """Get the KNX notification service.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + return async_get_service_discovery(hass, discovery_info) \ + if discovery_info is not None else \ + async_get_service_config(hass, config) + + +@callback +def async_get_service_discovery(hass, discovery_info): + """Set up notifications for KNX platform configured via xknx.yaml.""" + notification_devices = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + notification_devices.append(device) + return \ + KNXNotificationService(hass, notification_devices) \ + if notification_devices else \ + None + + +@callback +def async_get_service_config(hass, config): + """Set up notification for KNX platform configured within plattform.""" + import xknx + notification = xknx.devices.Notification( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(notification) + return KNXNotificationService(hass, [notification, ]) + + +class KNXNotificationService(BaseNotificationService): + """Implement demo notification service.""" + + def __init__(self, hass, devices): + """Initialize the service.""" + self.hass = hass + self.devices = devices + + @property + def targets(self): + """Return a dictionary of registered targets.""" + ret = {} + for device in self.devices: + ret[device.name] = device.name + return ret + + @asyncio.coroutine + def async_send_message(self, message="", **kwargs): + """Send a notification to knx bus.""" + if "target" in kwargs: + yield from self._async_send_to_device(message, kwargs["target"]) + else: + yield from self._async_send_to_all_devices(message) + + @asyncio.coroutine + def _async_send_to_all_devices(self, message): + """Send a notification to knx bus to all connected devices.""" + for device in self.devices: + yield from device.set(message) + + @asyncio.coroutine + def _async_send_to_device(self, message, names): + """Send a notification to knx bus to device with given names.""" + for device in self.devices: + if device.name in names: + yield from device.set(message) diff --git a/homeassistant/components/notify/mycroft.py b/homeassistant/components/notify/mycroft.py new file mode 100644 index 00000000000..1fd22c5c42b --- /dev/null +++ b/homeassistant/components/notify/mycroft.py @@ -0,0 +1,40 @@ +""" +Mycroft AI notification platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.mycroft/ +""" +import logging + + +from homeassistant.components.notify import BaseNotificationService + +DEPENDENCIES = ['mycroft'] + + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Mycroft notification service.""" + return MycroftNotificationService( + hass.data['mycroft']) + + +class MycroftNotificationService(BaseNotificationService): + """The Mycroft Notification Service.""" + + def __init__(self, mycroft_ip): + """Initialize the service.""" + self.mycroft_ip = mycroft_ip + + def send_message(self, message="", **kwargs): + """Send a message mycroft to speak on instance.""" + from mycroftapi import MycroftAPI + + text = message + mycroft = MycroftAPI(self.mycroft_ip) + if mycroft is not None: + mycroft.speak_text(text) + else: + _LOGGER.log("Could not reach this instance of mycroft") diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 0d596fb41ba..d8b67413528 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.pushbullet/ """ import logging +import mimetypes import voluptuous as vol @@ -20,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_URL = 'url' ATTR_FILE = 'file' +ATTR_FILE_URL = 'file_url' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, @@ -80,16 +82,11 @@ class PushBulletNotificationService(BaseNotificationService): targets = kwargs.get(ATTR_TARGET) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) data = kwargs.get(ATTR_DATA) - url = None - filepath = None - if data: - url = data.get(ATTR_URL, None) - filepath = data.get(ATTR_FILE, None) refreshed = False if not targets: # Backward compatibility, notify all devices in own account - self._push_data(filepath, message, title, self.pushbullet, url) + self._push_data(message, title, data, self.pushbullet) _LOGGER.info("Sent notification to self") return @@ -104,8 +101,7 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object # This also seems works to send to all devices in own account if ttype == 'email': - self._push_data(filepath, message, title, url, - self.pushbullet, tname) + self._push_data(message, title, data, self.pushbullet, tname) _LOGGER.info("Sent notification to email %s", tname) continue @@ -124,33 +120,47 @@ class PushBulletNotificationService(BaseNotificationService): # Attempt push_note on a dict value. Keys are types & target # name. Dict pbtargets has all *actual* targets. try: - self._push_data(filepath, message, title, url, + self._push_data(message, title, data, self.pbtargets[ttype][tname]) _LOGGER.info("Sent notification to %s/%s", ttype, tname) except KeyError: _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, filepath, message, title, url, pusher, tname=None): + def _push_data(self, message, title, data, pusher, tname=None): from pushbullet import PushError - from pushbullet import Device + if data is None: + data = {} + url = data.get(ATTR_URL) + filepath = data.get(ATTR_FILE) + file_url = data.get(ATTR_FILE_URL) try: if url: - if isinstance(pusher, Device): - pusher.push_link(title, url, body=message) - else: + if tname: pusher.push_link(title, url, body=message, email=tname) - elif filepath and self.hass.config.is_allowed_path(filepath): + else: + pusher.push_link(title, url, body=message) + elif filepath: + if not self.hass.config.is_allowed_path(filepath): + _LOGGER.error("Filepath is not valid or allowed.") + return with open(filepath, "rb") as fileh: filedata = self.pushbullet.upload_file(fileh, filepath) if filedata.get('file_type') == 'application/x-empty': - _LOGGER.error("Failed to send an empty file.") + _LOGGER.error("Can not send an empty file.") return pusher.push_file(title=title, body=message, **filedata) + elif file_url: + if not file_url.startswith('http'): + _LOGGER.error("Url should start with http or https.") + return + pusher.push_file(title=title, body=message, file_name=file_url, + file_url=file_url, + file_type=mimetypes.guess_type(file_url)[0]) else: - if isinstance(pusher, Device): - pusher.push_note(title, message) - else: + if tname: pusher.push_note(title, message, email=tname) + else: + pusher.push_note(title, message) except PushError as err: _LOGGER.error("Notify failed: %s", err) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index f67eae6c611..b7f192ff983 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -13,7 +13,7 @@ from homeassistant.components.notify import ( from homeassistant.const import (CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.0.0'] +REQUIREMENTS = ['sendgrid==5.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9d2a8c07932..25e6fc00a2f 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -8,6 +8,8 @@ import json import logging import mimetypes import os +from datetime import timedelta, datetime +from functools import partial import voluptuous as vol @@ -15,6 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME +from homeassistant.helpers.event import async_track_point_in_time REQUIREMENTS = ['TwitterAPI==2.4.6'] @@ -68,49 +71,67 @@ class TwitterNotificationService(BaseNotificationService): _LOGGER.warning("'%s' is not a whitelisted directory", media) return - media_id = self.upload_media(media) + callback = partial(self.send_message_callback, message) + self.upload_media_then_callback(callback, media) + + def send_message_callback(self, message, media_id): + """Tweet a message, optionally with media.""" if self.user: resp = self.api.request('direct_messages/new', - {'text': message, 'user': self.user, + {'user': self.user, + 'text': message, 'media_ids': media_id}) else: resp = self.api.request('statuses/update', - {'status': message, 'media_ids': media_id}) + {'status': message, + 'media_ids': media_id}) if resp.status_code != 200: self.log_error_resp(resp) + else: + _LOGGER.debug("Message posted: %s", resp.json()) - def upload_media(self, media_path=None): + def upload_media_then_callback(self, callback, media_path=None): """Upload media.""" if not media_path: return None + with open(media_path, 'rb') as file: + total_bytes = os.path.getsize(media_path) + (media_category, media_type) = self.media_info(media_path) + resp = self.upload_media_init( + media_type, media_category, total_bytes + ) + + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + media_id = resp.json()['media_id'] + media_id = self.upload_media_chunked(file, total_bytes, media_id) + + resp = self.upload_media_finalize(media_id) + if 199 > resp.status_code < 300: + self.log_error_resp(resp) + return None + + self.check_status_until_done(media_id, callback) + + def media_info(self, media_path): + """Determine mime type and Twitter media category for given media.""" (media_type, _) = mimetypes.guess_type(media_path) - total_bytes = os.path.getsize(media_path) + media_category = self.media_category_for_type(media_type) + _LOGGER.debug("media %s is mime type %s and translates to %s", + media_path, media_type, media_category) + return media_category, media_type - file = open(media_path, 'rb') - resp = self.upload_media_init(media_type, total_bytes) - - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - return None - - media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, media_id) - - resp = self.upload_media_finalize(media_id) - if 199 > resp.status_code < 300: - self.log_error_resp(resp) - - return media_id - - def upload_media_init(self, media_type, total_bytes): + def upload_media_init(self, media_type, media_category, total_bytes): """Upload media, INIT phase.""" - resp = self.api.request('media/upload', + return self.api.request('media/upload', {'command': 'INIT', 'media_type': media_type, + 'media_category': media_category, 'total_bytes': total_bytes}) - return resp def upload_media_chunked(self, file, total_bytes, media_id): """Upload media, chunked append.""" @@ -128,17 +149,55 @@ class TwitterNotificationService(BaseNotificationService): return media_id def upload_media_append(self, chunk, media_id, segment_id): - """Upload media, append phase.""" + """Upload media, APPEND phase.""" return self.api.request('media/upload', {'command': 'APPEND', 'media_id': media_id, 'segment_index': segment_id}, {'media': chunk}) def upload_media_finalize(self, media_id): - """Upload media, finalize phase.""" + """Upload media, FINALIZE phase.""" return self.api.request('media/upload', {'command': 'FINALIZE', 'media_id': media_id}) + def check_status_until_done(self, media_id, callback, *args): + """Upload media, STATUS phase.""" + resp = self.api.request('media/upload', + {'command': 'STATUS', 'media_id': media_id}, + method_override='GET') + if resp.status_code != 200: + _LOGGER.error("media processing error: %s", resp.json()) + processing_info = resp.json()['processing_info'] + + _LOGGER.debug("media processing %s status: %s", media_id, + processing_info) + + if processing_info['state'] in {u'succeeded', u'failed'}: + return callback(media_id) + + check_after_secs = processing_info['check_after_secs'] + _LOGGER.debug("media processing waiting %s seconds to check status", + str(check_after_secs)) + + when = datetime.now() + timedelta(seconds=check_after_secs) + myself = partial(self.check_status_until_done, media_id, callback) + async_track_point_in_time(self.hass, myself, when) + + @staticmethod + def media_category_for_type(media_type): + """Determine Twitter media category by mime type.""" + if media_type is None: + return None + + if media_type.startswith('image/gif'): + return 'tweet_gif' + elif media_type.startswith('video/'): + return 'tweet_video' + elif media_type.startswith('image/'): + return 'tweet_image' + + return None + @staticmethod def log_bytes_sent(bytes_sent, total_bytes): """Log upload progress.""" diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index d04eb91b6c4..f93e1b8f426 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -15,18 +15,20 @@ from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT REQUIREMENTS = ['sleekxmpp==1.3.2', 'dnspython3==1.15.0', - 'pyasn1==0.3.2', - 'pyasn1-modules==0.0.11'] + 'pyasn1==0.3.3', + 'pyasn1-modules==0.1.1'] _LOGGER = logging.getLogger(__name__) CONF_TLS = 'tls' +CONF_VERIFY = 'verify' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, + vol.Optional(CONF_VERIFY, default=True): cv.boolean, }) @@ -34,18 +36,20 @@ def get_service(hass, config, discovery_info=None): """Get the Jabber (XMPP) notification service.""" return XmppNotificationService( config.get(CONF_SENDER), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), config.get(CONF_TLS)) + config.get(CONF_RECIPIENT), config.get(CONF_TLS), + config.get(CONF_VERIFY)) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" - def __init__(self, sender, password, recipient, tls): + def __init__(self, sender, password, recipient, tls, verify): """Initialize the service.""" self._sender = sender self._password = password self._recipient = recipient self._tls = tls + self._verify = verify def send_message(self, message="", **kwargs): """Send a message to a user.""" @@ -53,10 +57,11 @@ class XmppNotificationService(BaseNotificationService): data = '{}: {}'.format(title, message) if title else message send_message('{}/home-assistant'.format(self._sender), self._password, - self._recipient, self._tls, data) + self._recipient, self._tls, self._verify, data) -def send_message(sender, password, recipient, use_tls, message): +def send_message(sender, password, recipient, use_tls, + verify_certificate, message): """Send a message over XMPP.""" import sleekxmpp @@ -73,6 +78,10 @@ def send_message(sender, password, recipient, use_tls, message): self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) self.add_event_handler('session_start', self.start) + if not verify_certificate: + self.add_event_handler('ssl_invalid_cert', + self.discard_ssl_invalid_cert) + self.connect(use_tls=self.use_tls, use_ssl=False) self.process() @@ -87,4 +96,10 @@ def send_message(sender, password, recipient, use_tls, message): """Disconnect from the server if credentials are invalid.""" self.disconnect() + @staticmethod + def discard_ssl_invalid_cert(event): + """Do nothing if ssl certificate is invalid.""" + _LOGGER.info('Ignoring invalid ssl certificate as requested.') + return + SendNotificationBot() diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index e3ffc2f24a8..0c5acd3f7fa 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,6 +4,7 @@ Support for RFXtrx components. For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ + import logging from collections import OrderedDict import voluptuous as vol @@ -11,13 +12,14 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ATTR_ENTITY_ID, TEMP_CELSIUS, CONF_DEVICE_CLASS, CONF_COMMAND_ON, CONF_COMMAND_OFF ) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyRFXtrx==0.19.0'] +REQUIREMENTS = ['pyRFXtrx==0.20.1'] DOMAIN = 'rfxtrx' @@ -54,7 +56,7 @@ DATA_TYPES = OrderedDict([ RECEIVED_EVT_SUBSCRIBERS = [] RFX_DEVICES = {} _LOGGER = logging.getLogger(__name__) -RFXOBJECT = None +RFXOBJECT = 'rfxobject' def _valid_device(value, device_type): @@ -77,10 +79,6 @@ def _valid_device(value, device_type): if not len(key) % 2 == 0: key = '0' + key - if get_rfx_object(key) is None: - raise vol.Invalid('Rfxtrx device {} is invalid: ' - 'Invalid device id for {}'.format(key, value)) - if device_type == 'sensor': config[key] = DEVICE_SCHEMA_SENSOR(device) elif device_type == 'binary_sensor': @@ -171,24 +169,24 @@ def setup(hass, config): # Try to load the RFXtrx module. import RFXtrx as rfxtrxmod - # Init the rfxtrx module. - global RFXOBJECT - device = config[DOMAIN][ATTR_DEVICE] debug = config[DOMAIN][ATTR_DEBUG] dummy_connection = config[DOMAIN][ATTR_DUMMY] if dummy_connection: - RFXOBJECT =\ - rfxtrxmod.Connect(device, handle_receive, debug=debug, + hass.data[RFXOBJECT] =\ + rfxtrxmod.Connect(device, None, debug=debug, transport_protocol=rfxtrxmod.DummyTransport2) else: - RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug) + hass.data[RFXOBJECT] = rfxtrxmod.Connect(device, None, debug=debug) + + def _start_rfxtrx(event): + hass.data[RFXOBJECT].event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx) def _shutdown_rfxtrx(event): """Close connection with RFXtrx.""" - RFXOBJECT.close_connection() - + hass.data[RFXOBJECT].close_connection() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx) return True @@ -285,13 +283,16 @@ def find_possible_pt2262_device(device_id): return None -def get_devices_from_config(config, device, hass): +def get_devices_from_config(config, device): """Read rfxtrx configuration.""" signal_repetitions = config[CONF_SIGNAL_REPETITIONS] devices = [] for packet_id, entity_info in config[CONF_DEVICES].items(): event = get_rfx_object(packet_id) + if event is None: + _LOGGER.error("Invalid device: %s", packet_id) + continue device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: continue @@ -303,13 +304,12 @@ def get_devices_from_config(config, device, hass): new_device = device(entity_info[ATTR_NAME], event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device devices.append(new_device) return devices -def get_new_device(event, config, device, hass): +def get_new_device(event, config, device): """Add entity if not exist and the automatic_add is True.""" device_id = slugify(event.device.id_string.lower()) if device_id in RFX_DEVICES: @@ -330,7 +330,6 @@ def get_new_device(event, config, device, hass): signal_repetitions = config[CONF_SIGNAL_REPETITIONS] new_device = device(pkt_id, event, datas, signal_repetitions) - new_device.hass = hass RFX_DEVICES[device_id] = new_device return new_device @@ -438,31 +437,36 @@ class RfxtrxDevice(Entity): if command == "turn_on": for _ in range(self.signal_repetitions): - self._event.device.send_on(RFXOBJECT.transport) + self._event.device.send_on(self.hass.data[RFXOBJECT] + .transport) self._state = True elif command == "dim": for _ in range(self.signal_repetitions): - self._event.device.send_dim(RFXOBJECT.transport, - brightness) + self._event.device.send_dim(self.hass.data[RFXOBJECT] + .transport, brightness) self._state = True elif command == 'turn_off': for _ in range(self.signal_repetitions): - self._event.device.send_off(RFXOBJECT.transport) + self._event.device.send_off(self.hass.data[RFXOBJECT] + .transport) self._state = False self._brightness = 0 elif command == "roll_up": for _ in range(self.signal_repetitions): - self._event.device.send_open(RFXOBJECT.transport) + self._event.device.send_open(self.hass.data[RFXOBJECT] + .transport) elif command == "roll_down": for _ in range(self.signal_repetitions): - self._event.device.send_close(RFXOBJECT.transport) + self._event.device.send_close(self.hass.data[RFXOBJECT] + .transport) elif command == "stop_roll": for _ in range(self.signal_repetitions): - self._event.device.send_stop(RFXOBJECT.transport) + self._event.device.send_stop(self.hass.data[RFXOBJECT] + .transport) self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 00000000000..7b077aa38ee --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_STATE, CONF_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 8961fa1dc74..1b5cfc4b491 100755 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -220,7 +220,12 @@ class BrSensor(Entity): # update all other sensors if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): - condition = data.get(FORECAST)[fcday].get(CONDITION) + try: + condition = data.get(FORECAST)[fcday].get(CONDITION) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False + if condition: new_state = condition.get(CONDITION, None) if self.type.startswith(SYMBOL): @@ -240,7 +245,11 @@ class BrSensor(Entity): return True return False else: - new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + try: + new_state = data.get(FORECAST)[fcday].get(self.type[:-3]) + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False if new_state != self._state: self._state = new_state diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 8fa34d50137..cbf06783dc7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -127,7 +127,7 @@ class DHTSensor(Entity): humidity_offset = self.humidity_offset data = self.dht_client.data - if self.type == SENSOR_TEMPERATURE: + if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data: temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) @@ -135,7 +135,7 @@ class DHTSensor(Entity): self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) - elif self.type == SENSOR_HUMIDITY: + elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data: humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py new file mode 100644 index 00000000000..0eeaa9424e8 --- /dev/null +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -0,0 +1,243 @@ +""" +Support for getting statistical data from a DWD Weather Warnings. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.dwd_weather_warnings/ + +Data is fetched from DWD: +https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html + +Warnungen vor extremem Unwetter (Stufe 4) +Unwetterwarnungen (Stufe 3) +Warnungen vor markantem Wetter (Stufe 2) +Wetterwarnungen (Stufe 1) +""" +import logging +import json +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor.rest import RestData + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by DWD" + +DEFAULT_NAME = 'DWD-Weather-Warnings' + +CONF_REGION_NAME = 'region_name' + +SCAN_INTERVAL = timedelta(minutes=15) + +MONITORED_CONDITIONS = { + 'current_warning_level': ['Current Warning Level', + None, 'mdi:close-octagon-outline'], + 'advance_warning_level': ['Advance Warning Level', + None, 'mdi:close-octagon-outline'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_REGION_NAME): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the DWD-Weather-Warnings sensor.""" + name = config.get(CONF_NAME) + region_name = config.get(CONF_REGION_NAME) + + api = DwdWeatherWarningsAPI(region_name) + + sensors = [DwdWeatherWarningsSensor(api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class DwdWeatherWarningsSensor(Entity): + """Representation of a DWD-Weather-Warnings sensor.""" + + def __init__(self, api, name, variable): + """Initialize a DWD-Weather-Warnings sensor.""" + self._api = api + self._name = name + self._var_id = variable + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable_info[0] + self._var_units = variable_info[1] + self._var_icon = variable_info[2] + + @property + def name(self): + """Return the name of the sensor.""" + return "{} {}".format(self._name, self._var_name) + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._var_icon + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._var_units + + # pylint: disable=no-member + @property + def state(self): + """Return the state of the device.""" + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] + + # pylint: disable=no-member + @property + def device_state_attributes(self): + """Return the state attributes of the DWD-Weather-Warnings.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + 'region_name': self._api.region_name + } + + if self._api.region_id is not None: + data['region_id'] = self._api.region_id + + if self._api.region_state is not None: + data['region_state'] = self._api.region_state + + if self._api.data['time'] is not None: + data['last_update'] = dt_util.as_local( + dt_util.utc_from_timestamp(self._api.data['time'] / 1000)) + + if self._var_id == 'current_warning_level': + prefix = 'current' + elif self._var_id == 'advance_warning_level': + prefix = 'advance' + else: + raise Exception('Unknown warning type') + + data['warning_count'] = self._api.data[prefix + '_warning_count'] + i = 0 + for event in self._api.data[prefix + '_warnings']: + i = i + 1 + + data['warning_{}_name'.format(i)] = event['event'] + data['warning_{}_level'.format(i)] = event['level'] + data['warning_{}_type'.format(i)] = event['type'] + if len(event['headline']) > 0: + data['warning_{}_headline'.format(i)] = event['headline'] + if len(event['description']) > 0: + data['warning_{}_description'.format(i)] = event['description'] + if len(event['instruction']) > 0: + data['warning_{}_instruction'.format(i)] = event['instruction'] + + if event['start'] is not None: + data['warning_{}_start'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['start'] / 1000)) + + if event['end'] is not None: + data['warning_{}_end'.format(i)] = dt_util.as_local( + dt_util.utc_from_timestamp(event['end'] / 1000)) + + return data + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._api.available + + def update(self): + """Get the latest data from the DWD-Weather-Warnings API.""" + self._api.update() + + +class DwdWeatherWarningsAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, region_name): + """Initialize the data object.""" + resource = "{}{}{}?{}".format( + 'https://', + 'www.dwd.de', + '/DWD/warnungen/warnapp_landkreise/json/warnings.json', + 'jsonp=loadWarnings' + ) + + self._rest = RestData('GET', resource, None, None, None, True) + self.region_name = region_name + self.region_id = None + self.region_state = None + self.data = None + self.available = True + self.update() + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from the DWD-Weather-Warnings.""" + try: + self._rest.update() + + json_string = self._rest.data[24:len(self._rest.data) - 2] + json_obj = json.loads(json_string) + + data = {'time': json_obj['time']} + + for mykey, myvalue in { + 'current': 'warnings', + 'advance': 'vorabInformation' + }.items(): + + _LOGGER.debug("Found %d %s global DWD warnings", + len(json_obj[myvalue]), mykey) + + data['{}_warning_level'.format(mykey)] = 0 + my_warnings = [] + + if self.region_id is not None: + # get a specific region_id + if self.region_id in json_obj[myvalue]: + my_warnings = json_obj[myvalue][self.region_id] + + else: + # loop through all items to find warnings, region_id + # and region_state for region_name + for key in json_obj[myvalue]: + my_region = json_obj[myvalue][key][0]['regionName'] + if my_region != self.region_name: + continue + my_warnings = json_obj[myvalue][key] + my_state = json_obj[myvalue][key][0]['stateShort'] + self.region_id = key + self.region_state = my_state + break + + # Get max warning level + maxlevel = data['{}_warning_level'.format(mykey)] + for event in my_warnings: + if event['level'] >= maxlevel: + data['{}_warning_level'.format(mykey)] = event['level'] + + data['{}_warning_count'.format(mykey)] = len(my_warnings) + data['{}_warnings'.format(mykey)] = my_warnings + + _LOGGER.debug("Found %d %s local DWD warnings", + len(my_warnings), mykey) + + self.data = data + self.available = True + except TypeError: + _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings") + self.available = False diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 605805c028d..5876a059672 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -260,13 +260,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): access_token = config_file.get(ATTR_ACCESS_TOKEN) refresh_token = config_file.get(ATTR_REFRESH_TOKEN) + expires_at = config_file.get(ATTR_LAST_SAVED_AT) if None not in (access_token, refresh_token): authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET), access_token=access_token, - refresh_token=refresh_token) + refresh_token=refresh_token, + expires_at=expires_at, + refresh_cb=lambda x: None) - if int(time.time()) - config_file.get(ATTR_LAST_SAVED_AT, 0) > 3600: + if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() authd_client.system = authd_client.user_profile_get()["user"]["locale"] @@ -338,12 +341,14 @@ class FitbitAuthCallbackView(HomeAssistantView): response_message = """Fitbit has been successfully authorized! You can close this window now!""" + result = None if data.get('code') is not None: redirect_uri = '{}{}'.format( hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH) try: - self.oauth.fetch_access_token(data.get('code'), redirect_uri) + result = self.oauth.fetch_access_token(data.get('code'), + redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) response_message = """Something went wrong when @@ -361,15 +366,23 @@ class FitbitAuthCallbackView(HomeAssistantView): An unknown error occurred. Please try again! """ + if result is None: + _LOGGER.error("Unknown error when authing") + response_message = """Something went wrong when + attempting authenticating with Fitbit. + An unknown error occurred. Please try again! + """ + html_response = """Fitbit Auth

{}

""".format(response_message) - config_contents = { - ATTR_ACCESS_TOKEN: self.oauth.token['access_token'], - ATTR_REFRESH_TOKEN: self.oauth.token['refresh_token'], - ATTR_CLIENT_ID: self.oauth.client_id, - ATTR_CLIENT_SECRET: self.oauth.client_secret - } + if result: + config_contents = { + ATTR_ACCESS_TOKEN: result.get('access_token'), + ATTR_REFRESH_TOKEN: result.get('refresh_token'), + ATTR_CLIENT_ID: self.oauth.client_id, + ATTR_CLIENT_SECRET: self.oauth.client_secret + } if not config_from_file(hass.config.path(FITBIT_CONFIG_FILE), config_contents): _LOGGER.error("Failed to save config file") @@ -490,9 +503,11 @@ class FitbitSensor(Entity): if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') + + token = self.client.client.session.token config_contents = { - ATTR_ACCESS_TOKEN: self.client.client.token['access_token'], - ATTR_REFRESH_TOKEN: self.client.client.token['refresh_token'], + ATTR_ACCESS_TOKEN: token.get('access_token'), + ATTR_REFRESH_TOKEN: token.get('refresh_token'), ATTR_CLIENT_ID: self.client.client.client_id, ATTR_CLIENT_SECRET: self.client.client.client_secret, ATTR_LAST_SAVED_AT: int(time.time()) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index 771b4a94bd4..061fd27ca69 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -57,8 +57,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSensor(hass, conf) - new_device.link_homematic() + new_device = HMSensor(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 80a88ca925a..60f11d76e79 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -1,184 +1,111 @@ """ -Sensors of a KNX Device. +Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/knx/ +https://home-assistant.io/components/sensor.knx/ """ -from enum import Enum - -import logging +import asyncio import voluptuous as vol -from homeassistant.const import ( - CONF_NAME, CONF_MAXIMUM, CONF_MINIMUM, - CONF_TYPE, TEMP_CELSIUS -) -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +CONF_ADDRESS = 'address' +CONF_TYPE = 'type' +DEFAULT_NAME = 'KNX Sensor' DEPENDENCIES = ['knx'] -DEFAULT_NAME = "KNX sensor" - -CONF_TEMPERATURE = 'temperature' -CONF_ADDRESS = 'address' -CONF_ILLUMINANCE = 'illuminance' -CONF_PERCENTAGE = 'percentage' -CONF_SPEED_MS = 'speed_ms' - - -class KNXAddressType(Enum): - """Enum to indicate conversion type for the KNX address.""" - - FLOAT = 1 - PERCENT = 2 - - -# define the fixed settings required for each sensor type -FIXED_SETTINGS_MAP = { - # Temperature as defined in KNX Standard 3.10 - 9.001 DPT_Value_Temp - CONF_TEMPERATURE: { - 'unit': TEMP_CELSIUS, - 'default_minimum': -273, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Speed m/s as defined in KNX Standard 3.10 - 9.005 DPT_Value_Wsp - CONF_SPEED_MS: { - 'unit': 'm/s', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Luminance(LUX) as defined in KNX Standard 3.10 - 9.004 DPT_Value_Lux - CONF_ILLUMINANCE: { - 'unit': 'lx', - 'default_minimum': 0, - 'default_maximum': 670760, - 'address_type': KNXAddressType.FLOAT - }, - # Percentage(%) as defined in KNX Standard 3.10 - 5.001 DPT_Scaling - CONF_PERCENTAGE: { - 'unit': '%', - 'default_minimum': 0, - 'default_maximum': 100, - 'address_type': KNXAddressType.PERCENT - } -} - -SENSOR_TYPES = set(FIXED_SETTINGS_MAP.keys()) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), vol.Required(CONF_ADDRESS): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_MINIMUM): vol.Coerce(float), - vol.Optional(CONF_MAXIMUM): vol.Coerce(float) + vol.Optional(CONF_TYPE): cv.string, }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX Sensor platform.""" - add_devices([KNXSensor(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up sensor(s) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSensor(KNXGroupAddress): - """Representation of a KNX Sensor device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up sensors for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSensor(hass, device)) + add_devices(entities) - def __init__(self, hass, config): - """Initialize a KNX Float Sensor.""" - # set up the KNX Group address - KNXGroupAddress.__init__(self, hass, config) - device_type = config.config.get(CONF_TYPE) - sensor_config = FIXED_SETTINGS_MAP.get(device_type) +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up sensor for KNX platform configured within plattform.""" + import xknx + sensor = xknx.devices.Sensor( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + value_type=config.get(CONF_TYPE)) + hass.data[DATA_KNX].xknx.devices.add(sensor) + add_devices([KNXSensor(hass, sensor)]) - if not sensor_config: - raise NotImplementedError() - # set up the conversion function based on the address type - address_type = sensor_config.get('address_type') - if address_type == KNXAddressType.FLOAT: - self.convert = convert_float - elif address_type == KNXAddressType.PERCENT: - self.convert = convert_percent - else: - raise NotImplementedError() +class KNXSensor(Entity): + """Representation of a KNX sensor.""" - # other settings - self._unit_of_measurement = sensor_config.get('unit') - default_min = float(sensor_config.get('default_minimum')) - default_max = float(sensor_config.get('default_maximum')) - self._minimum_value = config.config.get(CONF_MINIMUM, default_min) - self._maximum_value = config.config.get(CONF_MAXIMUM, default_max) - _LOGGER.debug( - "%s: configured additional settings: unit=%s, " - "min=%f, max=%f, type=%s", - self.name, self._unit_of_measurement, - self._minimum_value, self._maximum_value, str(address_type) - ) + def __init__(self, hass, device): + """Initialization of KNXSensor.""" + self.device = device + self.hass = hass + self.async_register_callbacks() - self._value = None + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False @property def state(self): - """Return the Value of the KNX Sensor.""" - return self._value + """Return the state of the sensor.""" + return self.device.resolve_state() @property def unit_of_measurement(self): - """Return the defined Unit of Measurement for the KNX Sensor.""" - return self._unit_of_measurement - - def update(self): - """Update KNX sensor.""" - super().update() - - self._value = None - - if self._data: - if self._data == 0: - value = 0 - else: - value = self.convert(self._data) - if self._minimum_value <= value <= self._maximum_value: - self._value = value + """Return the unit this state is expressed in.""" + return self.device.unit_of_measurement() @property - def cache(self): - """We don't want to cache any Sensor Value.""" - return False - - -def convert_float(raw_value): - """Conversion for 2 byte floating point values. - - 2byte Floating Point KNX Telegram. - Defined in KNX 3.7.2 - 3.10 - """ - from knxip.conversion import knx2_to_float - from knxip.core import KNXException - - try: - return knx2_to_float(raw_value) - except KNXException as exception: - _LOGGER.error("Can't convert %s to float (%s)", raw_value, exception) - - -def convert_percent(raw_value): - """Conversion for scaled byte values. - - 1byte percentage scaled KNX Telegram. - Defined in KNX 3.7.2 - 3.10. - """ - value = 0 - try: - value = raw_value[0] - except (IndexError, ValueError): - # pknx returns a non-iterable type for unsuccessful reads - _LOGGER.error("Can't convert %s to percent value", raw_value) - - return round(value * 100 / 255) + def device_state_attributes(self): + """Return the state attributes.""" + return None diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py new file mode 100644 index 00000000000..0184cb2afdf --- /dev/null +++ b/homeassistant/components/sensor/mopar.py @@ -0,0 +1,165 @@ +""" +Sensor for Mopar vehicles. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.mopar/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PIN, + ATTR_ATTRIBUTION, ATTR_COMMAND, + LENGTH_KILOMETERS) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['motorparts==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +MIN_TIME_BETWEEN_UPDATES = timedelta(days=7) +DOMAIN = 'mopar' +ATTR_VEHICLE_INDEX = 'vehicle_index' +SERVICE_REMOTE_COMMAND = 'remote_command' +COOKIE_FILE = 'mopar_cookies.pickle' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_PIN): cv.positive_int +}) + +REMOTE_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_COMMAND): cv.string, + vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Mopar platform.""" + import motorparts + cookie = hass.config.path(COOKIE_FILE) + try: + session = motorparts.get_session(config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + config.get(CONF_PIN), + cookie_path=cookie) + except motorparts.MoparError: + _LOGGER.error("failed to login") + return False + + def _handle_service(service): + """Handle service call.""" + index = service.data.get(ATTR_VEHICLE_INDEX) + command = service.data.get(ATTR_COMMAND) + try: + motorparts.remote_command(session, command, index) + except motorparts.MoparError as error: + _LOGGER.error(str(error)) + + hass.services.register(DOMAIN, SERVICE_REMOTE_COMMAND, _handle_service, + schema=REMOTE_COMMAND_SCHEMA) + + data = MoparData(session) + add_devices([MoparSensor(data, index) + for index, _ in enumerate(data.vehicles)], + True) + return True + + +# pylint: disable=too-few-public-methods +class MoparData(object): + """Container for Mopar vehicle data. + + Prevents session expiry re-login race condition. + """ + + def __init__(self, session): + """Initialize data.""" + self._session = session + self.vehicles = [] + self.vhrs = {} + self.tow_guides = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self, **kwargs): + """Update data.""" + import motorparts + _LOGGER.info("updating vehicle data") + try: + self.vehicles = motorparts.get_summary(self._session)['vehicles'] + except motorparts.MoparError: + _LOGGER.exception("failed to get summary") + return + for index, _ in enumerate(self.vehicles): + try: + self.vhrs[index] = motorparts.get_report(self._session, index) + self.tow_guides[index] = motorparts.get_tow_guide( + self._session, index) + except motorparts.MoparError: + _LOGGER.warning("failed to update for vehicle index %s", index) + + +class MoparSensor(Entity): + """Mopar vehicle sensor.""" + + def __init__(self, data, index): + """Initialize the sensor.""" + self._index = index + self._vehicle = {} + self._vhr = {} + self._tow_guide = {} + self._odometer = None + self._data = data + + def update(self): + """Update device state.""" + self._data.update() + self._vehicle = self._data.vehicles[self._index] + self._vhr = self._data.vhrs.get(self._index, {}) + self._tow_guide = self._data.tow_guides.get(self._index, {}) + if 'odometer' in self._vhr: + odo = float(self._vhr['odometer']) + self._odometer = int(self.hass.config.units.length( + odo, LENGTH_KILOMETERS)) + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {} {}'.format(self._vehicle['year'], + self._vehicle['make'], + self._vehicle['model']) + + @property + def state(self): + """Return the state of the sensor.""" + return self._odometer + + @property + def device_state_attributes(self): + """Return the state attributes.""" + import motorparts + attributes = { + ATTR_VEHICLE_INDEX: self._index, + ATTR_ATTRIBUTION: motorparts.ATTRIBUTION + } + attributes.update(self._vehicle) + attributes.update(self._vhr) + attributes.update(self._tow_guide) + return attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.length_unit + + @property + def icon(self): + """Return the icon.""" + return 'mdi:car' diff --git a/homeassistant/components/sensor/radarr.py b/homeassistant/components/sensor/radarr.py index 03fbce3e79a..33a09a51aef 100644 --- a/homeassistant/components/sensor/radarr.py +++ b/homeassistant/components/sensor/radarr.py @@ -162,7 +162,7 @@ class RadarrSensor(Entity): res = requests.get( ENDPOINTS[self.type].format( self.ssl, self.host, self.port, self.urlbase, start, end), - headers={'X-Api-Key': self.apikey}, timeout=5) + headers={'X-Api-Key': self.apikey}, timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py new file mode 100644 index 00000000000..e02f3cac2b0 --- /dev/null +++ b/homeassistant/components/sensor/season.py @@ -0,0 +1,122 @@ +""" +Support for tracking which astronomical or meteorological season it is. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor/season/ +""" +import logging +from datetime import datetime + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_TYPE +from homeassistant.helpers.entity import Entity +import homeassistant.util as util + +REQUIREMENTS = ['ephem==3.7.6.0'] + +_LOGGER = logging.getLogger(__name__) + +NORTHERN = 'northern' +SOUTHERN = 'southern' +EQUATOR = 'equator' +STATE_SPRING = 'Spring' +STATE_SUMMER = 'Summer' +STATE_AUTUMN = 'Autumn' +STATE_WINTER = 'Winter' +TYPE_ASTRONOMICAL = 'astronomical' +TYPE_METEOROLOGICAL = 'meteorological' +VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] + +HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, + STATE_SPRING: STATE_AUTUMN, + STATE_AUTUMN: STATE_SPRING, + STATE_SUMMER: STATE_WINTER} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Display the current season.""" + if None in (hass.config.latitude, hass.config.longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return False + + latitude = util.convert(hass.config.latitude, float) + _type = config.get(CONF_TYPE) + + if latitude < 0: + hemisphere = SOUTHERN + elif latitude > 0: + hemisphere = NORTHERN + else: + hemisphere = EQUATOR + + _LOGGER.debug(_type) + add_devices([Season(hass, hemisphere, _type)]) + + return True + + +def get_season(date, hemisphere, season_tracking_type): + """Calculate the current season.""" + import ephem + + if hemisphere == 'equator': + return None + + if season_tracking_type == TYPE_ASTRONOMICAL: + spring_start = ephem.next_equinox(str(date.year)).datetime() + summer_start = ephem.next_solstice(str(date.year)).datetime() + autumn_start = ephem.next_equinox(spring_start).datetime() + winter_start = ephem.next_solstice(summer_start).datetime() + else: + spring_start = datetime(2017, 3, 1).replace(year=date.year) + summer_start = spring_start.replace(month=6) + autumn_start = spring_start.replace(month=9) + winter_start = spring_start.replace(month=12) + + if spring_start <= date < summer_start: + season = STATE_SPRING + elif summer_start <= date < autumn_start: + season = STATE_SUMMER + elif autumn_start <= date < winter_start: + season = STATE_AUTUMN + elif winter_start <= date or spring_start > date: + season = STATE_WINTER + + # If user is located in the southern hemisphere swap the season + if hemisphere == NORTHERN: + return season + return HEMISPHERE_SEASON_SWAP.get(season) + + +class Season(Entity): + """Representation of the current season.""" + + def __init__(self, hass, hemisphere, season_tracking_type): + """Initialize the season.""" + self.hass = hass + self.hemisphere = hemisphere + self.datetime = datetime.now() + self.type = season_tracking_type + self.season = get_season(self.datetime, self.hemisphere, self.type) + + @property + def name(self): + """Return the name.""" + return "Season" + + @property + def state(self): + """Return the current season.""" + return self.season + + def update(self): + """Update season.""" + self.datetime = datetime.now() + self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index c95d975ec47..3d86d940f4d 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.4'] +REQUIREMENTS = ['shodan==1.7.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 143fcee0a61..4be5582b8c4 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -36,17 +36,19 @@ SENSOR_TYPES = { 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'], 'wanted': ['Wanted', 'Episodes', 'mdi:television'], 'series': ['Series', 'Shows', 'mdi:television'], - 'commands': ['Commands', 'Commands', 'mdi:code-braces'] + 'commands': ['Commands', 'Commands', 'mdi:code-braces'], + 'status': ['Status', 'Status', 'mdi:information'] } ENDPOINTS = { - 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace?apikey={4}', - 'queue': 'http{0}://{1}:{2}/{3}api/queue?apikey={4}', + 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace', + 'queue': 'http{0}://{1}:{2}/{3}api/queue', 'upcoming': - 'http{0}://{1}:{2}/{3}api/calendar?apikey={4}&start={5}&end={6}', - 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing?apikey={4}', - 'series': 'http{0}://{1}:{2}/{3}api/series?apikey={4}', - 'commands': 'http{0}://{1}:{2}/{3}api/command?apikey={4}' + 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}', + 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing', + 'series': 'http{0}://{1}:{2}/{3}api/series', + 'commands': 'http{0}://{1}:{2}/{3}api/command', + 'status': 'http{0}://{1}:{2}/{3}api/system/status' } # Support to Yottabytes for the future, why not @@ -156,6 +158,8 @@ class SonarrSensor(Entity): for show in self.data: attributes[show['title']] = '{}/{} Episodes'.format( show['episodeFileCount'], show['episodeCount']) + elif self.type == 'status': + attributes = self.data return attributes @property @@ -168,9 +172,12 @@ class SonarrSensor(Entity): start = get_date(self._tz) end = get_date(self._tz, self.days) try: - res = requests.get(ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, self.apikey, - start, end), timeout=5) + res = requests.get( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, + self.urlbase, start, end), + headers={'X-Api-Key': self.apikey}, + timeout=10) except OSError: _LOGGER.error("Host %s is not available", self.host) self._available = False @@ -193,10 +200,13 @@ class SonarrSensor(Entity): self._state = len(self.data) elif self.type == 'wanted': data = res.json() - res = requests.get('{}&pageSize={}'.format( - ENDPOINTS[self.type].format( - self.ssl, self.host, self.port, self.urlbase, - self.apikey), data['totalRecords']), timeout=5) + res = requests.get( + '{}?pageSize={}'.format( + ENDPOINTS[self.type].format( + self.ssl, self.host, self.port, self.urlbase), + data['totalRecords']), + headers={'X-Api-Key': self.apikey}, + timeout=10) self.data = res.json()['records'] self._state = len(self.data) elif self.type == 'diskspace': @@ -217,6 +227,9 @@ class SonarrSensor(Entity): self._unit ) ) + elif self.type == 'status': + self.data = res.json() + self._state = self.data['version'] self._available = True diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 2d7b74e8791..34d3cabf26b 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -18,6 +18,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,6 +35,8 @@ ATTR_SAMPLING_SIZE = 'sampling_size' ATTR_TOTAL = 'total' CONF_SAMPLING_SIZE = 'sampling_size' +CONF_MAX_AGE = 'max_age' + DEFAULT_NAME = 'Stats' DEFAULT_SIZE = 20 ICON = 'mdi:calculator' @@ -41,7 +44,9 @@ ICON = 'mdi:calculator' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): cv.positive_int, + vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_MAX_AGE): cv.time_period }) @@ -51,16 +56,18 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = config.get(CONF_ENTITY_ID) name = config.get(CONF_NAME) sampling_size = config.get(CONF_SAMPLING_SIZE) + max_age = config.get(CONF_MAX_AGE, None) async_add_devices( - [StatisticsSensor(hass, entity_id, name, sampling_size)], True) + [StatisticsSensor(hass, entity_id, name, sampling_size, max_age)], + True) return True class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size): + def __init__(self, hass, entity_id, name, sampling_size, max_age): """Initialize the Statistics sensor.""" self._hass = hass self._entity_id = entity_id @@ -71,11 +78,12 @@ class StatisticsSensor(Entity): else: self._name = '{} {}'.format(name, ATTR_COUNT) self._sampling_size = sampling_size + self._max_age = max_age self._unit_of_measurement = None - if self._sampling_size == 0: - self.states = deque() - else: - self.states = deque(maxlen=self._sampling_size) + self.states = deque(maxlen=self._sampling_size) + if self._max_age is not None: + self.ages = deque(maxlen=self._sampling_size) + self.median = self.mean = self.variance = self.stdev = 0 self.min = self.max = self.total = self.count = 0 self.average_change = self.change = 0 @@ -89,6 +97,9 @@ class StatisticsSensor(Entity): try: self.states.append(float(new_state.state)) + if self._max_age is not None: + now = dt_util.utcnow() + self.ages.append(now) self.count = self.count + 1 except ValueError: self.count = self.count + 1 @@ -128,8 +139,7 @@ class StatisticsSensor(Entity): ATTR_MAX_VALUE: self.max, ATTR_MEDIAN: self.median, ATTR_MIN_VALUE: self.min, - ATTR_SAMPLING_SIZE: 'unlimited' if self._sampling_size is - 0 else self._sampling_size, + ATTR_SAMPLING_SIZE: self._sampling_size, ATTR_STANDARD_DEVIATION: self.stdev, ATTR_TOTAL: self.total, ATTR_VARIANCE: self.variance, @@ -142,9 +152,20 @@ class StatisticsSensor(Entity): """Return the icon to use in the frontend, if any.""" return ICON + def _purge_old(self): + """Remove states which are older than self._max_age.""" + now = dt_util.utcnow() + + while (len(self.ages) > 0) and (now - self.ages[0]) > self._max_age: + self.ages.popleft() + self.states.popleft() + @asyncio.coroutine def async_update(self): """Get the latest data and updates the states.""" + if self._max_age is not None: + self._purge_old() + if not self.is_binary: try: self.mean = round(statistics.mean(self.states), 2) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 42229351fde..69a82fb0fac 100755 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -16,10 +16,12 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.2.2'] +REQUIREMENTS = ['psutil==5.3.0'] _LOGGER = logging.getLogger(__name__) +CONF_ARG = 'arg' + SENSOR_TYPES = { 'disk_free': ['Disk Free', 'GiB', 'mdi:harddisk'], 'disk_use': ['Disk Use', 'GiB', 'mdi:harddisk'], @@ -49,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES), - vol.Optional('arg'): cv.string, + vol.Optional(CONF_ARG): cv.string, })]) }) @@ -71,9 +73,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] for resource in config[CONF_RESOURCES]: - if 'arg' not in resource: - resource['arg'] = '' - dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource['arg'])) + if CONF_ARG not in resource: + resource[CONF_ARG] = '' + dev.append(SystemMonitorSensor( + resource[CONF_TYPE], resource[CONF_ARG])) add_devices(dev, True) diff --git a/homeassistant/components/sensor/tank_utility.py b/homeassistant/components/sensor/tank_utility.py new file mode 100644 index 00000000000..01ace415159 --- /dev/null +++ b/homeassistant/components/sensor/tank_utility.py @@ -0,0 +1,138 @@ +""" +Support for the Tank Utility propane monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tank_utility/ +""" + +import datetime +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, + STATE_UNKNOWN) +from homeassistant.helpers.entity import Entity + + +REQUIREMENTS = [ + "tank_utility==1.4.0" +] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1)) +}) + +SENSOR_TYPE = "tank" +SENSOR_ROUNDING_PRECISION = 1 +SENSOR_UNIT_OF_MEASUREMENT = "%" +SENSOR_ATTRS = [ + "name", + "address", + "capacity", + "fuelType", + "orientation", + "status", + "time", + "time_iso" +] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tank Utility sensor.""" + from tank_utility import auth + email = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICES) + + try: + token = auth.get_token(email, password) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.error("Invalid credentials") + return + + all_sensors = [] + for device in devices: + sensor = TankUtilitySensor(email, password, token, device) + all_sensors.append(sensor) + add_devices(all_sensors, True) + + +class TankUtilitySensor(Entity): + """Representation of a Tank Utility sensor.""" + + def __init__(self, email, password, token, device): + """Initialize the sensor.""" + self._email = email + self._password = password + self._token = token + self._device = device + self._state = STATE_UNKNOWN + self._name = "Tank Utility " + self.device + self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._attributes = {} + + @property + def device(self): + """Return the device identifier.""" + return self._device + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of the device.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + return self._attributes + + def get_data(self): + """Get data from the device. + + Flatten dictionary to map device to map of device data. + + """ + from tank_utility import auth, device + data = {} + try: + data = device.get_device_data(self._token, self.device) + except requests.exceptions.HTTPError as http_error: + if (http_error.response.status_code == + requests.codes.unauthorized): # pylint: disable=no-member + _LOGGER.info("Getting new token") + self._token = auth.get_token(self._email, self._password, + force=True) + data = device.get_device_data(self._token, self.device) + else: + raise http_error + data.update(data.pop("device", {})) + data.update(data.pop("lastReading", {})) + return data + + def update(self): + """Set the device state and attributes.""" + data = self.get_data() + self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION) + self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS} diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py new file mode 100644 index 00000000000..fc31a5543e2 --- /dev/null +++ b/homeassistant/components/sensor/tesla.py @@ -0,0 +1,82 @@ +""" +Sensors for the Tesla sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.tesla/ +""" +import logging +from datetime import timedelta + +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.tesla import DOMAIN as TESLA_DOMAIN, TeslaDevice +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['tesla'] + +SCAN_INTERVAL = timedelta(minutes=5) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Tesla sensor platform.""" + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + + for device in hass.data[TESLA_DOMAIN]['devices']['sensor']: + if device.bin_type == 0x4: + devices.append(TeslaSensor(device, controller, 'inside')) + devices.append(TeslaSensor(device, controller, 'outside')) + else: + devices.append(TeslaSensor(device, controller)) + add_devices(devices, True) + + +class TeslaSensor(TeslaDevice, Entity): + """Representation of Tesla sensors.""" + + def __init__(self, tesla_device, controller, sensor_type=None): + """Initialisation of the sensor.""" + self.current_value = None + self._temperature_units = None + self.last_changed_time = None + self.type = sensor_type + super().__init__(tesla_device, controller) + + if self.type: + self._name = '{} ({})'.format(self.tesla_device.name, self.type) + self.entity_id = ENTITY_ID_FORMAT.format( + '{}_{}'.format(self.tesla_id, self.type)) + else: + self._name = self.tesla_device.name + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._temperature_units + + def update(self): + """Update the state from the sensor.""" + _LOGGER.debug("Updating sensor: %s", self._name) + self.tesla_device.update() + if self.tesla_device.bin_type == 0x4: + if self.type == 'outside': + self.current_value = self.tesla_device.get_outside_temp() + else: + self.current_value = self.tesla_device.get_inside_temp() + + tesla_temp_units = self.tesla_device.measurement + + if tesla_temp_units == 'F': + self._temperature_units = TEMP_FAHRENHEIT + else: + self._temperature_units = TEMP_CELSIUS + else: + self.current_value = self.tesla_device.battery_level() diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index 5d8ff49cd5b..eb7050309bc 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['uber_rides==0.5.1'] +REQUIREMENTS = ['uber_rides==0.5.2'] _LOGGER = logging.getLogger(__name__) @@ -87,11 +87,14 @@ class UberSensor(Entity): if self._product.get('price_details') is not None: price_details = self._product['price_details'] self._unit_of_measurement = price_details.get('currency_code') - if price_details.get('low_estimate') is not None: - statekey = 'minimum' - else: - statekey = 'low_estimate' - self._state = int(price_details.get(statekey, 0)) + try: + if price_details.get('low_estimate') is not None: + statekey = 'minimum' + else: + statekey = 'low_estimate' + self._state = int(price_details.get(statekey)) + except TypeError: + self._state = 0 else: self._state = 0 diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index c9a42f3cb11..f23d244cf3a 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -88,7 +88,7 @@ class WorldTidesInfoSensor(Entity): return "High tide at %s" % (tidetime) elif "Low" in str(self.data['extremes'][0]['type']): tidetime = time.strftime('%I:%M %p', time.localtime( - self.data['extremes'][1]['dt'])) + self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) else: return STATE_UNKNOWN diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 7315b6dc2d2..57820917cab 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -546,3 +546,28 @@ rflink: command: description: The command to be sent example: 'on' + +counter: + decrement: + description: Decrement a counter. + + fields: + entity_id: + description: Entity id of the counter to decrement. + example: 'counter.count0' + + increment: + description: Increment a counter. + + fields: + entity_id: + description: Entity id of the counter to increment. + example: 'counter.count0' + + reset: + description: Reset a counter. + + fields: + entity_id: + description: Entity id of the counter to reset. + example: 'counter.count0' diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 6243de0b2d6..1f64f78e9c8 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -66,7 +66,7 @@ def async_setup(hass, config): yield from intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) except intent.IntentError: - _LOGGER.exception("Error while handling intent.") + _LOGGER.exception("Error while handling intent: %s.", intent_type) yield from hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) diff --git a/homeassistant/components/switch/abode.py b/homeassistant/components/switch/abode.py new file mode 100644 index 00000000000..bed0b9c0b60 --- /dev/null +++ b/homeassistant/components/switch/abode.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Abode Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.abode/ +""" +import logging + +from homeassistant.components.abode import AbodeDevice, DATA_ABODE +from homeassistant.components.switch import SwitchDevice + + +DEPENDENCIES = ['abode'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Abode switch devices.""" + import abodepy.helpers.constants as CONST + + abode = hass.data[DATA_ABODE] + + device_types = [ + CONST.DEVICE_POWER_SWITCH_SENSOR, + CONST.DEVICE_POWER_SWITCH_METER] + + sensors = [] + for sensor in abode.get_devices(type_filter=device_types): + sensors.append(AbodeSwitch(abode, sensor)) + + add_devices(sensors) + + +class AbodeSwitch(AbodeDevice, SwitchDevice): + """Representation of an Abode switch.""" + + def __init__(self, controller, device): + """Initialize the Abode device.""" + AbodeDevice.__init__(self, controller, device) + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index 26493122184..0625a42f765 100755 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -46,7 +46,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import dlipower host = config.get(CONF_HOST) - controllername = config.get(CONF_NAME) + controller_name = config.get(CONF_NAME) user = config.get(CONF_USERNAME) pswd = config.get(CONF_PASSWORD) tout = config.get(CONF_TIMEOUT) @@ -61,37 +61,42 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to DIN III Relay") return False - devices = [] + outlets = [] parent_device = DINRelayDevice(power_switch) - devices.extend( - DINRelay(controllername, device.outlet_number, parent_device) - for device in power_switch + outlets.extend( + DINRelay(controller_name, parent_device, outlet) + for outlet in power_switch[0:] ) - add_devices(devices, True) + add_devices(outlets) class DINRelay(SwitchDevice): """Representation of a individual DIN III relay port.""" - def __init__(self, name, outletnumber, parent_device): + def __init__(self, controller_name, parent_device, outlet): """Initialize the DIN III Relay switch.""" + self._controller_name = controller_name self._parent_device = parent_device - self.controllername = name - self.outletnumber = outletnumber - self._outletname = '' - self._is_on = False + self._outlet = outlet + + self._outlet_number = self._outlet.outlet_number + self._name = self._outlet.description + self._state = self._outlet.state == 'ON' @property def name(self): """Return the display name of this relay.""" - return self._outletname + return '{}_{}'.format( + self._controller_name, + self._name + ) @property def is_on(self): """Return true if relay is on.""" - return self._is_on + return self._state @property def should_poll(self): @@ -100,41 +105,36 @@ class DINRelay(SwitchDevice): def turn_on(self, **kwargs): """Instruct the relay to turn on.""" - self._parent_device.turn_on(outlet=self.outletnumber) + self._outlet.on() def turn_off(self, **kwargs): """Instruct the relay to turn off.""" - self._parent_device.turn_off(outlet=self.outletnumber) + self._outlet.off() def update(self): """Trigger update for all switches on the parent device.""" self._parent_device.update() - self._is_on = ( - self._parent_device.statuslocal[self.outletnumber - 1][2] == 'ON' - ) - self._outletname = '{}_{}'.format( - self.controllername, - self._parent_device.statuslocal[self.outletnumber - 1][1] - ) + + outlet_status = self._parent_device.get_outlet_status( + self._outlet_number) + + self._name = outlet_status[1] + self._state = outlet_status[2] == 'ON' class DINRelayDevice(object): """Device representation for per device throttling.""" - def __init__(self, device): + def __init__(self, power_switch): """Initialize the DINRelay device.""" - self._device = device - self.statuslocal = None + self._power_switch = power_switch + self._statuslist = None - def turn_on(self, **kwargs): - """Instruct the relay to turn on.""" - self._device.on(**kwargs) - - def turn_off(self, **kwargs): - """Instruct the relay to turn off.""" - self._device.off(**kwargs) + def get_outlet_status(self, outlet_number): + """Get status of outlet from cached status list.""" + return self._statuslist[outlet_number - 1] @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Fetch new state data for this device.""" - self.statuslocal = self._device.statuslist() + self._statuslist = self._power_switch.statuslist() diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index b24693da616..f6ed6dac018 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN -REQUIREMENTS = ['pyW215==0.5.1'] +REQUIREMENTS = ['pyW215==0.6.0'] _LOGGER = logging.getLogger(__name__) @@ -23,9 +23,9 @@ DEFAULT_PASSWORD = '' DEFAULT_USERNAME = 'admin' CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TEMPERATURE = 'Temperature' +ATTR_CURRENT_CONSUMPTION = 'power_consumption' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TEMPERATURE = 'temperature' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index 5613bcbb19e..e8bd592cee8 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -6,8 +6,9 @@ The idea was taken from https://github.com/KpaBap/hue-flux/ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.flux/ """ -from datetime import time +import datetime import logging + import voluptuous as vol from homeassistant.components.light import is_on, turn_on @@ -46,7 +47,7 @@ PLATFORM_SCHEMA = vol.Schema({ vol.Required(CONF_LIGHTS): cv.entity_ids, vol.Optional(CONF_NAME, default="Flux"): cv.string, vol.Optional(CONF_START_TIME): cv.time, - vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time, + vol.Optional(CONF_STOP_TIME, default=datetime.time(22, 0)): cv.time, vol.Optional(CONF_START_CT, default=4000): vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)), vol.Optional(CONF_SUNSET_CT, default=3000): @@ -171,12 +172,22 @@ class FluxSwitch(SwitchDevice): """Update all the lights using flux.""" if now is None: now = dt_now() + sunset = get_astral_event_date(self.hass, 'sunset', now.date()) start_time = self.find_start_time(now) stop_time = now.replace( hour=self._stop_time.hour, minute=self._stop_time.minute, second=0) + if stop_time <= start_time: + # stop_time does not happen in the same day as start_time + if start_time < now: + # stop time is tomorrow + stop_time += datetime.timedelta(days=1) + elif now < start_time: + # stop_time was yesterday since the new start_time is not reached + stop_time -= datetime.timedelta(days=1) + if start_time < now < sunset: # Daytime time_state = 'day' @@ -192,15 +203,24 @@ class FluxSwitch(SwitchDevice): else: # Nightime time_state = 'night' - if now < stop_time and now > start_time: - now_time = now + + if now < stop_time: + if stop_time < start_time and stop_time.day == sunset.day: + # we need to use yesterday's sunset time + sunset_time = sunset - datetime.timedelta(days=1) + else: + sunset_time = sunset + + # pylint: disable=no-member + night_length = int(stop_time.timestamp() - + sunset_time.timestamp()) + seconds_from_sunset = int(now.timestamp() - + sunset_time.timestamp()) + percentage_complete = seconds_from_sunset / night_length else: - now_time = stop_time + percentage_complete = 1 + temp_range = abs(self._sunset_colortemp - self._stop_colortemp) - night_length = int(stop_time.timestamp() - sunset.timestamp()) - seconds_from_sunset = int(now_time.timestamp() - - sunset.timestamp()) - percentage_complete = seconds_from_sunset / night_length temp_offset = temp_range * percentage_complete if self._sunset_colortemp > self._stop_colortemp: temp = self._sunset_colortemp - temp_offset diff --git a/homeassistant/components/switch/homematic.py b/homeassistant/components/switch/homematic.py index 566eff99828..487947598bb 100644 --- a/homeassistant/components/switch/homematic.py +++ b/homeassistant/components/switch/homematic.py @@ -21,8 +21,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for conf in discovery_info[ATTR_DISCOVER_DEVICES]: - new_device = HMSwitch(hass, conf) - new_device.link_homematic() + new_device = HMSwitch(conf) devices.append(new_device) add_devices(devices) diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index d07df08ed5c..90b04239086 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -1,14 +1,16 @@ """ -Support KNX switching actuators. +Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ +import asyncio import voluptuous as vol -from homeassistant.components.knx import (KNXConfig, KNXGroupAddress) -from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_NAME +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv CONF_ADDRESS = 'address' @@ -24,30 +26,85 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the KNX switch platform.""" - add_devices([KNXSwitch(hass, KNXConfig(config))]) +@asyncio.coroutine +def async_setup_platform(hass, config, add_devices, + discovery_info=None): + """Set up switch(es) for KNX platform.""" + if DATA_KNX not in hass.data \ + or not hass.data[DATA_KNX].initialized: + return False + + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, add_devices) + else: + async_add_devices_config(hass, config, add_devices) + + return True -class KNXSwitch(KNXGroupAddress, SwitchDevice): - """Representation of a KNX switch device.""" +@callback +def async_add_devices_discovery(hass, discovery_info, add_devices): + """Set up switches for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXSwitch(hass, device)) + add_devices(entities) - def turn_on(self, **kwargs): - """Turn the switch on. - This sends a value 0 to the group address of the device - """ - self.group_write(1) - self._state = [1] - if not self.should_poll: - self.schedule_update_ha_state() +@callback +def async_add_devices_config(hass, config, add_devices): + """Set up switch for KNX platform configured within plattform.""" + import xknx + switch = xknx.devices.Switch( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + group_address_state=config.get(CONF_STATE_ADDRESS)) + hass.data[DATA_KNX].xknx.devices.add(switch) + add_devices([KNXSwitch(hass, switch)]) - def turn_off(self, **kwargs): - """Turn the switch off. - This sends a value 1 to the group address of the device - """ - self.group_write(0) - self._state = [0] - if not self.should_poll: - self.schedule_update_ha_state() +class KNXSwitch(SwitchDevice): + """Representation of a KNX switch.""" + + def __init__(self, hass, device): + """Initialization of KNXSwitch.""" + self.device = device + self.hass = hass + self.async_register_callbacks() + + @callback + def async_register_callbacks(self): + """Register callbacks to update hass after device was changed.""" + @asyncio.coroutine + def after_update_callback(device): + """Callback after device was updated.""" + # pylint: disable=unused-argument + yield from self.async_update_ha_state() + self.device.register_device_updated_cb(after_update_callback) + + @property + def name(self): + """Return the name of the KNX device.""" + return self.device.name + + @property + def should_poll(self): + """No polling needed within KNX.""" + return False + + @property + def is_on(self): + """Return true if device is on.""" + return self.device.state + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn the device on.""" + yield from self.device.set_on() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn the device off.""" + yield from self.device.set_off() diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index 585dc043315..daaba68dc5e 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -8,7 +8,7 @@ import logging from homeassistant.components.lutron_caseta import ( LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice) -from homeassistant.components.switch import SwitchDevice +from homeassistant.components.switch import SwitchDevice, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Lutron switch.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - switch_devices = bridge.get_devices_by_type("WallSwitch") + switch_devices = bridge.get_devices_by_domain(DOMAIN) for switch_device in switch_devices: dev = LutronCasetaLight(switch_device, bridge) diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 36044f5f168..1361d22de18 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): import RFXtrx as rfxtrxmod # Add switch from config file - switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch, hass) + switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch) add_devices_callback(switches) def switch_update(event): @@ -31,7 +31,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): event.device.known_to_be_rollershutter: return - new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch, hass) + new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch) if new_device: add_devices_callback([new_device]) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 38669ff4ee6..de9c0f4ede3 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -24,7 +24,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==7.0.1'] +REQUIREMENTS = ['python-telegram-bot==8.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tesla.py b/homeassistant/components/tesla.py new file mode 100644 index 00000000000..08006310dc7 --- /dev/null +++ b/homeassistant/components/tesla.py @@ -0,0 +1,123 @@ +""" +Support for Tesla cars. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tesla/ +""" +from collections import defaultdict +import logging + +from urllib.error import HTTPError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify + +REQUIREMENTS = ['teslajsonpy==0.0.11'] + +DOMAIN = 'tesla' + +_LOGGER = logging.getLogger(__name__) + +TESLA_ID_FORMAT = '{}_{}' +TESLA_ID_LIST_SCHEMA = vol.Schema([int]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=300): + vol.All(cv.positive_int, vol.Clamp(min=300)), + }), +}, extra=vol.ALLOW_EXTRA) + +NOTIFICATION_ID = 'tesla_integration_notification' +NOTIFICATION_TITLE = 'Tesla integration setup' + +TESLA_COMPONENTS = [ + 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker' +] + + +def setup(hass, base_config): + """Set up of Tesla platform.""" + from teslajsonpy.controller import Controller as teslaApi + + config = base_config.get(DOMAIN) + + email = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + update_interval = config.get(CONF_SCAN_INTERVAL) + if hass.data.get(DOMAIN) is None: + try: + hass.data[DOMAIN] = { + 'controller': teslaApi(email, password, update_interval), + 'devices': defaultdict(list) + } + _LOGGER.debug("Connected to the Tesla API.") + except HTTPError as ex: + if ex.code == 401: + hass.components.persistent_notification.create( + "Error:
Please check username and password." + "You will need to restart Home Assistant after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + else: + hass.components.persistent_notification.create( + "Error:
Can't communicate with Tesla API.
" + "Error code: {} Reason: {}" + "You will need to restart Home Assistant after fixing." + "".format(ex.code, ex.reason), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + _LOGGER.error("Unable to communicate with Tesla API: %s", + ex.reason) + + return False + + all_devices = hass.data[DOMAIN]['controller'].list_vehicles() + + if not all_devices: + return False + + for device in all_devices: + hass.data[DOMAIN]['devices'][device.hass_type].append(device) + + for component in TESLA_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, base_config) + + return True + + +class TeslaDevice(Entity): + """Representation of a Tesla device.""" + + def __init__(self, tesla_device, controller): + """Initialisation of the Tesla device.""" + self.tesla_device = tesla_device + self.controller = controller + self._name = self.tesla_device.name + self.tesla_id = slugify(self.tesla_device.uniq_name) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from tesla device.""" + return self.tesla_device.should_poll + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + if self.tesla_device.has_battery(): + attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level() + return attr diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 31938cd15ff..34422819743 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -16,12 +16,13 @@ from homeassistant.helpers import discovery from homeassistant.const import CONF_HOST, CONF_API_KEY from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI -REQUIREMENTS = ['pytradfri==1.1'] +REQUIREMENTS = ['pytradfri==2.2'] DOMAIN = 'tradfri' CONFIG_FILE = 'tradfri.conf' KEY_CONFIG = 'tradfri_configuring' KEY_GATEWAY = 'tradfri_gateway' +KEY_API = 'tradfri_api' KEY_TRADFRI_GROUPS = 'tradfri_allow_tradfri_groups' CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups' DEFAULT_ALLOW_TRADFRI_GROUPS = True @@ -109,17 +110,21 @@ def async_setup(hass, config): @asyncio.coroutine def _setup_gateway(hass, hass_config, host, key, allow_tradfri_groups): """Create a gateway.""" - from pytradfri import cli_api_factory, Gateway, RequestError, retry_timeout + from pytradfri import Gateway, RequestError + from pytradfri.api.libcoap_api import api_factory try: - api = retry_timeout(cli_api_factory(host, key)) + api = api_factory(host, key) except RequestError: return False - gateway = Gateway(api) - gateway_id = gateway.get_gateway_info().id + gateway = Gateway() + # pylint: disable=no-member + gateway_id = api(gateway.get_gateway_info()).id + hass.data.setdefault(KEY_API, {}) hass.data.setdefault(KEY_GATEWAY, {}) gateways = hass.data[KEY_GATEWAY] + hass.data[KEY_API][gateway_id] = api hass.data.setdefault(KEY_TRADFRI_GROUPS, {}) tradfri_groups = hass.data[KEY_TRADFRI_GROUPS] diff --git a/homeassistant/components/vacuum/xiaomi.py b/homeassistant/components/vacuum/xiaomi.py index 5e5081a2aa8..95d7478aa9f 100644 --- a/homeassistant/components/vacuum/xiaomi.py +++ b/homeassistant/components/vacuum/xiaomi.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-mirobo==0.1.2'] +REQUIREMENTS = ['python-mirobo==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 5903bed1fc7..9c8366e7f7e 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -73,14 +73,7 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - class state: # pylint:disable=invalid-name - """Namespace to hold state for each vehicle.""" - - entities = {} - vehicles = {} - names = config[DOMAIN].get(CONF_NAME) - - hass.data[DATA_KEY] = state + state = hass.data[DATA_KEY] = VolvoData(config) def discover_vehicle(vehicle): """Load relevant platforms.""" @@ -120,6 +113,31 @@ def setup(hass, config): return update(utcnow()) +class VolvoData: + """Hold component state.""" + + def __init__(self, config): + """Initialize the component state.""" + self.entities = {} + self.vehicles = {} + self.names = config[DOMAIN].get(CONF_NAME) + + def vehicle_name(self, vehicle): + """Provide a friendly name for a vehicle.""" + if (vehicle.registration_number and + vehicle.registration_number.lower()) in self.names: + return self.names[vehicle.registration_number.lower()] + elif (vehicle.vin and + vehicle.vin.lower() in self.names): + return self.names[vehicle.vin.lower()] + elif vehicle.registration_number: + return vehicle.registration_number + elif vehicle.vin: + return vehicle.vin + else: + return '' + + class VolvoEntity(Entity): """Base class for all VOC entities.""" @@ -139,17 +157,14 @@ class VolvoEntity(Entity): """Return vehicle.""" return self._state.vehicles[self._vin] - @property - def _vehicle_name(self): - return (self._state.names.get(self._vin.lower()) or - self._state.names.get( - self.vehicle.registration_number.lower()) or - self.vehicle.registration_number) - @property def _entity_name(self): return RESOURCES[self._attribute][1] + @property + def _vehicle_name(self): + return self._state.vehicle_name(self.vehicle) + @property def name(self): """Return full name of the entity.""" diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3d7226e3c8b..0592ad4c124 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.19'] +REQUIREMENTS = ['pywemo==0.4.20'] DOMAIN = 'wemo' diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py index f79f414f0db..1d14a76d251 100644 --- a/homeassistant/components/xiaomi.py +++ b/homeassistant/components/xiaomi.py @@ -9,7 +9,7 @@ from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' - '0.3.zip#PyXiaomiGateway==0.3.0'] + '0.3.2.zip#PyXiaomiGateway==0.3.2'] ATTR_GW_MAC = 'gw_mac' ATTR_RINGTONE_ID = 'ringtone_id' diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index b72d9eb0cff..a238d01d520 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -10,6 +10,7 @@ ATTR_VALUE_ID = "value_id" ATTR_OBJECT_ID = "object_id" ATTR_NAME = "name" ATTR_SCENE_ID = "scene_id" +ATTR_SCENE_DATA = "scene_data" ATTR_BASIC_LEVEL = "basic_level" ATTR_CONFIG_PARAMETER = "parameter" ATTR_CONFIG_SIZE = "size" diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 3a810d00d2d..44a30cdc529 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from .const import ( - ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_BASIC_LEVEL, - EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN) + ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, + ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, DOMAIN, + COMMAND_CLASS_CENTRAL_SCENE) from .util import node_name _LOGGER = logging.getLogger(__name__) @@ -107,13 +108,19 @@ class ZWaveNodeEntity(ZWaveBaseEntity): dispatcher.connect( self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT) - def network_node_changed(self, node=None, args=None): + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: return if args is not None and 'nodeId' in args and \ args['nodeId'] != self.node_id: return + + # Process central scene activation + if (value is not None and + value.command_class == COMMAND_CLASS_CENTRAL_SCENE): + self.central_scene_activated(value.index, value.data) + self.node_changed() def get_node_statistics(self): @@ -177,6 +184,18 @@ class ZWaveNodeEntity(ZWaveBaseEntity): ATTR_SCENE_ID: scene_id }) + def central_scene_activated(self, scene_id, scene_data): + """Handle an activated central scene for this node.""" + if self.hass is None: + return + + self.hass.bus.fire(EVENT_SCENE_ACTIVATED, { + ATTR_ENTITY_ID: self.entity_id, + ATTR_NODE_ID: self.node_id, + ATTR_SCENE_ID: scene_id, + ATTR_SCENE_DATA: scene_data + }) + @property def state(self): """Return the state.""" diff --git a/homeassistant/config.py b/homeassistant/config.py index c90c4517397..ee48ece67ab 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -57,6 +57,7 @@ DEFAULT_CORE_CONFIG = ( CONF_UNIT_SYSTEM_IMPERIAL)), (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), + (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), ) # type: Tuple[Tuple[str, Any, Any, str], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend @@ -176,12 +177,15 @@ def create_default_config(config_dir, detect_location=True): CONFIG_PATH as AUTOMATION_CONFIG_PATH) from homeassistant.components.config.script import ( CONFIG_PATH as SCRIPT_CONFIG_PATH) + from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) config_path = os.path.join(config_dir, YAML_CONFIG_FILE) version_path = os.path.join(config_dir, VERSION_FILE) group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH) automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH) script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH) + customize_yaml_path = os.path.join(config_dir, CUSTOMIZE_CONFIG_PATH) info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG} @@ -229,6 +233,9 @@ def create_default_config(config_dir, detect_location=True): with open(script_yaml_path, 'wt'): pass + with open(customize_yaml_path, 'wt'): + pass + return config_path except IOError: diff --git a/homeassistant/const.py b/homeassistant/const.py index 15079b11992..88ab58201f8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 52 -PATCH_VERSION = '1' +MINOR_VERSION = 53 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -101,6 +101,7 @@ CONF_EVENT = 'event' CONF_EXCLUDE = 'exclude' CONF_FILE_PATH = 'file_path' CONF_FILENAME = 'filename' +CONF_FOR = 'for' CONF_FRIENDLY_NAME = 'friendly_name' CONF_HEADERS = 'headers' CONF_HOST = 'host' diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 9b64c08af18..5db4ece5ef5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -113,6 +113,62 @@ def async_track_template(hass, template, action, variables=None): track_template = threaded_listener_factory(async_track_template) +@callback +def async_track_same_state(hass, orig_value, period, action, + async_check_func=None, entity_ids=MATCH_ALL): + """Track the state of entities for a period and run a action. + + If async_check_func is None it use the state of orig_value. + Without entity_ids we track all state changes. + """ + async_remove_state_for_cancel = None + async_remove_state_for_listener = None + + @callback + def clear_listener(): + """Clear all unsub listener.""" + nonlocal async_remove_state_for_cancel, async_remove_state_for_listener + + # pylint: disable=not-callable + if async_remove_state_for_listener is not None: + async_remove_state_for_listener() + async_remove_state_for_listener = None + if async_remove_state_for_cancel is not None: + async_remove_state_for_cancel() + async_remove_state_for_cancel = None + + @callback + def state_for_listener(now): + """Fire on state changes after a delay and calls action.""" + nonlocal async_remove_state_for_listener + async_remove_state_for_listener = None + clear_listener() + hass.async_run_job(action) + + @callback + def state_for_cancel_listener(entity, from_state, to_state): + """Fire on changes and cancel for listener if changed.""" + if async_check_func: + value = async_check_func(entity, from_state, to_state) + else: + value = to_state.state + + if orig_value == value: + return + clear_listener() + + async_remove_state_for_listener = async_track_point_in_utc_time( + hass, state_for_listener, dt_util.utcnow() + period) + + async_remove_state_for_cancel = async_track_state_change( + hass, entity_ids, state_for_cancel_listener) + + return clear_listener + + +track_same_state = threaded_listener_factory(async_track_same_state) + + @callback def async_track_point_in_time(hass, action, point_in_time): """Add a listener that fires once after a specific point in time.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index bdef5541983..d5dbcb77a32 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -187,6 +187,10 @@ class AllStates(object): sorted(self._hass.states.async_all(), key=lambda state: state.entity_id)) + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids()) + def __call__(self, entity_id): """Return the states.""" state = self._hass.states.get(entity_id) @@ -213,6 +217,10 @@ class DomainStates(object): if state.domain == self._domain), key=lambda state: state.entity_id)) + def __len__(self): + """Return number of states.""" + return len(self._hass.states.async_entity_ids(self._domain)) + class TemplateState(State): """Class to represent a state object in a template.""" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bea6d6fbe40..43de2a54dbb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,11 +2,11 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.3.0 chardet==3.0.4 astral==1.4 diff --git a/requirements_all.txt b/requirements_all.txt index 7ec81558945..703bbd6b184 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3,11 +3,11 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 pip>=8.0.3 -jinja2>=2.9.5 +jinja2>=2.9.6 voluptuous==0.10.5 typing>=3,<4 aiohttp==2.2.5 -async_timeout==1.2.1 +async_timeout==1.3.0 chardet==3.0.4 astral==1.4 @@ -18,7 +18,7 @@ astral==1.4 # Adafruit_BBIO==1.0.0 # homeassistant.components.isy994 -PyISY==1.0.7 +PyISY==1.0.8 # homeassistant.components.notify.html5 PyJWT==1.5.2 @@ -39,7 +39,7 @@ SoCo==0.12 TwitterAPI==2.4.6 # homeassistant.components.abode -abodepy==0.7.1 +abodepy==0.9.0 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.2 @@ -166,13 +166,13 @@ datapoint==0.4.3 # decora_wifi==1.3 # homeassistant.components.media_player.denonavr -denonavr==0.5.2 +denonavr==0.5.3 # homeassistant.components.media_player.directv directpy==0.1 # homeassistant.components.notify.discord -discord.py==0.16.10 +discord.py==0.16.11 # homeassistant.components.updater distro==1.0.4 @@ -202,6 +202,9 @@ enocean==0.31 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.keyboard_remote # evdev==0.6.1 @@ -210,7 +213,7 @@ evohomeclient==0.2.5 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify -# face_recognition==0.2.2 +# face_recognition==1.0.0 # homeassistant.components.sensor.fastdotcom fastdotcom==0.0.1 @@ -296,7 +299,7 @@ holidays==0.8.1 http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a # homeassistant.components.xiaomi -https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.zip#PyXiaomiGateway==0.3.0 +https://github.com/Danielhiversen/PyXiaomiGateway/archive/0.3.2.zip#PyXiaomiGateway==0.3.2 # homeassistant.components.sensor.dht # https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 @@ -359,9 +362,6 @@ jsonrpc-websocket==0.5 # homeassistant.scripts.keyring keyring>=9.3,<10.0 -# homeassistant.components.knx -knxip==0.5 - # homeassistant.components.device_tracker.owntracks libnacl==1.5.2 @@ -412,9 +412,15 @@ miflora==0.1.16 # homeassistant.components.upnp miniupnpc==1.9 +# homeassistant.components.sensor.mopar +motorparts==1.0.0 + # homeassistant.components.tts mutagen==1.38 +# homeassistant.components.mycroft +mycroftapi==2.0 + # homeassistant.components.usps myusps==1.1.3 @@ -501,7 +507,7 @@ proliphix==0.4.1 prometheus_client==0.0.19 # homeassistant.components.sensor.systemmonitor -psutil==5.2.2 +psutil==5.3.0 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -527,10 +533,13 @@ pyCEC==0.4.13 pyHS100==0.2.4.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.19.0 +pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink -pyW215==0.5.1 +pyW215==0.6.0 + +# homeassistant.components.sensor.airvisual +pyairvisual==0.1.0 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0 @@ -539,10 +548,10 @@ pyalarmdotcom==0.3.0 pyarlo==0.0.4 # homeassistant.components.notify.xmpp -pyasn1-modules==0.0.11 +pyasn1-modules==0.1.1 # homeassistant.components.notify.xmpp -pyasn1==0.3.2 +pyasn1==0.3.3 # homeassistant.components.apple_tv pyatv==0.3.4 @@ -600,7 +609,7 @@ pyfttt==0.3 pyharmony==1.0.16 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.3 +pyhik==0.1.4 # homeassistant.components.homematic pyhomematic==0.1.30 @@ -637,7 +646,7 @@ pylitejet==0.1 pyloopenergy==0.0.17 # homeassistant.components.lutron_caseta -pylutron-caseta==0.2.7 +pylutron-caseta==0.2.8 # homeassistant.components.lutron pylutron==0.1.0 @@ -651,14 +660,17 @@ pymochad==0.1.1 # homeassistant.components.modbus pymodbus==1.3.1 +# homeassistant.components.media_player.yamaha_musiccast +pymusiccast==0.1.0 + # homeassistant.components.cover.myq pymyq==0.0.8 # homeassistant.components.mysensors -pymysensors==0.11.0 +pymysensors==0.11.1 # homeassistant.components.lock.nello -pynello==1.5 +pynello==1.5.1 # homeassistant.components.device_tracker.netgear pynetgear==0.3.3 @@ -740,8 +752,9 @@ python-juicenet==0.0.5 # homeassistant.components.lirc # python-lirc==1.2.3 +# homeassistant.components.light.xiaomi_philipslight # homeassistant.components.vacuum.xiaomi -python-mirobo==0.1.2 +python-mirobo==0.1.3 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -772,7 +785,7 @@ python-synology==0.1.0 python-tado==0.2.2 # homeassistant.components.telegram_bot -python-telegram-bot==7.0.1 +python-telegram-bot==8.0.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 @@ -790,13 +803,13 @@ python-wink==1.5.1 python_openzwave==0.4.0.31 # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.18 +pythonegardia==1.0.20 # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri==1.1 +pytradfri==2.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -817,7 +830,7 @@ pyvlx==0.1.3 pywebpush==1.0.6 # homeassistant.components.wemo -pywemo==0.4.19 +pywemo==0.4.20 # homeassistant.components.zabbix pyzabbix==0.7.4 @@ -871,7 +884,7 @@ schiene==0.18 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.0.0 +sendgrid==5.2.0 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat @@ -881,7 +894,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.4 +shodan==1.7.5 # homeassistant.components.alarm_control_panel.simplisafe simplisafe-python==1.0.5 @@ -923,6 +936,9 @@ steamodd==4.21 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.tank_utility +tank_utility==1.4.0 + # homeassistant.components.binary_sensor.tapsaff tapsaff==0.1.3 @@ -936,6 +952,9 @@ tellduslive==0.3.4 # homeassistant.components.sensor.temper temperusb==1.5.3 +# homeassistant.components.tesla +teslajsonpy==0.0.11 + # homeassistant.components.thingspeak thingspeak==0.4.1 @@ -953,7 +972,7 @@ transmissionrpc==0.11 twilio==5.7.0 # homeassistant.components.sensor.uber -uber_rides==0.5.1 +uber_rides==0.5.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 @@ -993,6 +1012,9 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.knx +xknx==0.7.13 + # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 @@ -1013,7 +1035,7 @@ yeelight==0.3.2 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.8.18 +youtube_dl==2017.9.2 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index 7a79d7bff6d..ef9487836c0 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.6.3 -sphinx-autodoc-typehints==1.2.1 +sphinx-autodoc-typehints==1.2.3 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f286555833e..7695f83497b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,6 +39,9 @@ apns2==0.1.1 # homeassistant.components.sensor.dsmr dsmr_parser==0.8 +# homeassistant.components.sensor.season +ephem==3.7.6.0 + # homeassistant.components.climate.honeywell evohomeclient==0.2.5 diff --git a/script/build_frontend b/script/build_frontend index 12a4fefca05..3eee66daf36 100755 --- a/script/build_frontend +++ b/script/build_frontend @@ -16,13 +16,13 @@ rm -rf homeassistant/components/frontend/www_static/core.js* \ cd homeassistant/components/frontend/www_static/home-assistant-polymer # Build frontend -BUILD_DEV=0 gulp +BUILD_DEV=0 ./node_modules/.bin/gulp cp bower_components/webcomponentsjs/webcomponents-lite.js .. cp bower_components/webcomponentsjs/custom-elements-es5-adapter.js .. cp build/*.js build/*.html .. mkdir ../panels cp build/panels/*.html ../panels -BUILD_DEV=0 gulp gen-service-worker +BUILD_DEV=0 ./node_modules/.bin/gulp gen-service-worker cp build/service_worker.js .. cd .. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index ba7a49cc7c0..8a215cd2873 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -70,6 +70,7 @@ TEST_REQUIREMENTS = ( 'restrictedpython', 'pyunifi', 'prometheus_client', + 'ephem' ) IGNORE_PACKAGES = ( diff --git a/setup.py b/setup.py index 78a6a0bba71..63f77820ca7 100755 --- a/setup.py +++ b/setup.py @@ -19,11 +19,11 @@ REQUIRES = [ 'pyyaml>=3.11,<4', 'pytz>=2017.02', 'pip>=8.0.3', - 'jinja2>=2.9.5', + 'jinja2>=2.9.6', 'voluptuous==0.10.5', 'typing>=3,<4', 'aiohttp==2.2.5', - 'async_timeout==1.2.1', + 'async_timeout==1.3.0', 'chardet==3.0.4', 'astral==1.4', ] diff --git a/tests/common.py b/tests/common.py index 5fdec2fc411..f0d6a5bd057 100644 --- a/tests/common.py +++ b/tests/common.py @@ -119,7 +119,7 @@ def async_test_home_assistant(loop): def async_add_job(target, *args): """Add a magic mock.""" if isinstance(target, Mock): - return mock_coro(target()) + return mock_coro(target(*args)) return orig_async_add_job(target, *args) hass.async_add_job = async_add_job diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index e5d819bc815..063f3361148 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -72,6 +72,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_HOME)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -150,6 +155,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_AWAY)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -228,6 +238,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_ARMED_NIGHT)) + future = dt_util.utcnow() + timedelta(seconds=1) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -314,6 +329,11 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_PENDING, self.hass.states.get(entity_id).state) + self.assertTrue( + self.hass.states.is_state_attr(entity_id, + 'post_pending_state', + STATE_ALARM_TRIGGERED)) + future = dt_util.utcnow() + timedelta(seconds=2) with patch(('homeassistant.components.alarm_control_panel.manual.' 'dt_util.utcnow'), return_value=future): @@ -364,6 +384,97 @@ class TestAlarmControlPanelManual(unittest.TestCase): self.assertEqual(STATE_ALARM_DISARMED, self.hass.states.get(entity_id).state) + def test_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + def test_back_to_back_trigger_with_no_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_AWAY, + self.hass.states.get(entity_id).state) + def test_disarm_while_pending_trigger(self): """Test disarming while pending state.""" self.assertTrue(setup_component( diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 355e26abf9b..0a7db4a122d 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -1,11 +1,16 @@ """The tests for numeric state automation.""" +from datetime import timedelta import unittest +from unittest.mock import patch +import homeassistant.components.automation as automation from homeassistant.core import callback from homeassistant.setup import setup_component -import homeassistant.components.automation as automation +import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant, mock_component +from tests.common import ( + get_test_home_assistant, mock_component, fire_time_changed, + assert_setup_component) # pylint: disable=invalid-name @@ -576,3 +581,126 @@ class TestAutomationNumericState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(2, len(self.calls)) + + def test_if_fails_setup_bad_for(self): + """Test for setup failure for bad for.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'invalid': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_fails_setup_for_without_above_below(self): + """Test for setup failures for missing above or below.""" + with assert_setup_component(0): + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'homeassistant.turn_on', + } + }}) + + def test_if_not_fires_on_entity_change_with_for(self): + """Test for not firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + self.hass.states.set('test.entity', 15) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + + def test_if_fires_on_entity_change_with_for_attribute_change(self): + """Test for firing on entity change with for and attribute change.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + utcnow = dt_util.utcnow() + with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow: + mock_utcnow.return_value = utcnow + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.states.set('test.entity', 9, + attributes={"mock_attr": "attr_change"}) + self.hass.block_till_done() + self.assertEqual(0, len(self.calls)) + mock_utcnow.return_value += timedelta(seconds=4) + fire_time_changed(self.hass, mock_utcnow.return_value) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) + + def test_if_fires_on_entity_change_with_for(self): + """Test for firing on entity change with for.""" + assert setup_component(self.hass, automation.DOMAIN, { + automation.DOMAIN: { + 'trigger': { + 'platform': 'numeric_state', + 'entity_id': 'test.entity', + 'above': 8, + 'below': 12, + 'for': { + 'seconds': 5 + }, + }, + 'action': { + 'service': 'test.automation' + } + } + }) + + self.hass.states.set('test.entity', 9) + self.hass.block_till_done() + fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10)) + self.hass.block_till_done() + self.assertEqual(1, len(self.calls)) diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py new file mode 100644 index 00000000000..f86047f3a3d --- /dev/null +++ b/tests/components/binary_sensor/test_bayesian.py @@ -0,0 +1,176 @@ +"""The test for the bayesian sensor platform.""" +import unittest + +from homeassistant.setup import setup_component +from homeassistant.components.binary_sensor import bayesian + +from tests.common import get_test_home_assistant + + +class TestBayesianBinarySensor(unittest.TestCase): + """Test the threshold sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_sensor_numeric_state(self): + """Test sensor on numeric state platform observations.""" + config = { + 'binary_sensor': { + 'platform': + 'bayesian', + 'name': + 'Test_Binary', + 'observations': [{ + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored', + 'below': 10, + 'above': 5, + 'prob_given_true': 0.6 + }, { + 'platform': 'numeric_state', + 'entity_id': 'sensor.test_monitored1', + 'below': 7, + 'above': 5, + 'prob_given_true': 0.9, + 'prob_given_false': 0.1 + }], + 'prior': + 0.2, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 6) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_false': 0.4, + 'prob_true': 0.6 + }, { + 'prob_false': 0.1, + 'prob_true': 0.9 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.7714285714285715, + state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 6) + self.hass.states.set('sensor.test_monitored1', 0) + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 4) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 15) + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + + assert state.state == 'off' + + def test_sensor_state(self): + """Test sensor on state platform observations.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'off', + 'prob_given_true': 0.8, + 'prob_given_false': 0.4 + }], + 'prior': + 0.2, + 'probability_threshold': + 0.32, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + + state = self.hass.states.get('binary_sensor.test_binary') + + self.assertEqual([], state.attributes.get('observations')) + self.assertEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertEqual([{ + 'prob_true': 0.8, + 'prob_false': 0.4 + }], state.attributes.get('observations')) + self.assertAlmostEqual(0.33333333, state.attributes.get('probability')) + + assert state.state == 'on' + + self.hass.states.set('sensor.test_monitored', 'off') + self.hass.block_till_done() + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(0.2, state.attributes.get('probability')) + + assert state.state == 'off' + + def test_probability_updates(self): + """Test probability update function.""" + prob_true = [0.3, 0.6, 0.8] + prob_false = [0.7, 0.4, 0.2] + prior = 0.5 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.720000, prior) + + prob_true = [0.8, 0.3, 0.9] + prob_false = [0.6, 0.4, 0.2] + prior = 0.7 + + for pt, pf in zip(prob_true, prob_false): + prior = bayesian.update_probability(prior, pt, pf) + + self.assertAlmostEqual(0.9130434782608695, prior) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 4e829b42fe3..11163d42ab5 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,5 +1,6 @@ """The tests for the Template Binary sensor platform.""" import asyncio +from datetime import timedelta import unittest from unittest import mock @@ -10,10 +11,12 @@ from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError from homeassistant.helpers import template as template_hlpr from homeassistant.util.async import run_callback_threadsafe +import homeassistant.util.dt as dt_util from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE from tests.common import ( - get_test_home_assistant, assert_setup_component, mock_component) + get_test_home_assistant, assert_setup_component, mock_component, + async_fire_time_changed) class TestBinarySensorTemplate(unittest.TestCase): @@ -103,19 +106,20 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() self.assertFalse(vs.should_poll) self.assertEqual('motion', vs.device_class) self.assertEqual('Parent', vs.name) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertFalse(vs.is_on) # pylint: disable=protected-access vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass) - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() self.assertTrue(vs.is_on) def test_event(self): @@ -155,13 +159,14 @@ class TestBinarySensorTemplate(unittest.TestCase): vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', - template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL + template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL, + None, None ).result() mock_render.side_effect = TemplateError('foo') - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() mock_render.side_effect = TemplateError( "UndefinedError: 'None' has no attribute") - vs.update() + run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() @asyncio.coroutine @@ -197,3 +202,124 @@ def test_restore_state(hass): state = hass.states.get('binary_sensor.test') assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_on(hass): + """Test binary sensor template delay on.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_on': 5 + }, + }, + }, + } + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + # check with time changes + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + +@asyncio.coroutine +def test_template_delay_off(hass): + """Test binary sensor template delay off.""" + config = { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'test': { + 'friendly_name': 'virtual thingy', + 'value_template': + "{{ states.sensor.test_state.state == 'on' }}", + 'device_class': 'motion', + 'delay_off': 5 + }, + }, + }, + } + hass.states.async_set('sensor.test_state', 'on') + yield from setup.async_setup_component(hass, 'binary_sensor', config) + yield from hass.async_start() + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'off' + + # check with time changes + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'off') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + hass.states.async_set('sensor.test_state', 'on') + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' + + future = dt_util.utcnow() + timedelta(seconds=5) + async_fire_time_changed(hass, future) + yield from hass.async_block_till_done() + + state = hass.states.get('binary_sensor.test') + assert state.state == 'on' diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py new file mode 100644 index 00000000000..707e49f670f --- /dev/null +++ b/tests/components/cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the cloud component.""" diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000..11c396daf05 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,352 @@ +"""Tests for the tools to communicate with the cloud.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch +from urllib.parse import urljoin + +import aiohttp +import pytest + +from homeassistant.components.cloud import DOMAIN, cloud_api, const +import homeassistant.util.dt as dt_util + +from tests.common import mock_coro + + +MOCK_AUTH = { + "access_token": "jvCHxpTu2nfORLBRgQY78bIAoK4RPa", + "expires_at": "2017-08-29T05:33:28.266048+00:00", + "expires_in": 86400, + "refresh_token": "C4wR1mgb03cs69EeiFgGOBC8mMQC5Q", + "scope": "", + "token_type": "Bearer" +} + + +def url(path): + """Create a url.""" + return urljoin(const.SERVERS['development']['host'], path) + + +@pytest.fixture +def cloud_hass(hass): + """Fixture to return a hass instance with cloud mode set.""" + hass.data[DOMAIN] = {'mode': 'development'} + return hass + + +@pytest.fixture +def mock_write(): + """Mock reading authentication.""" + with patch.object(cloud_api, '_write_auth') as mock: + yield mock + + +@pytest.fixture +def mock_read(): + """Mock writing authentication.""" + with patch.object(cloud_api, '_read_auth') as mock: + yield mock + + +@asyncio.coroutine +def test_async_login_invalid_auth(cloud_hass, aioclient_mock, mock_write): + """Test trying to login with invalid credentials.""" + aioclient_mock.post(url('o/token/'), status=401) + with pytest.raises(cloud_api.Unauthenticated): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_cloud_error(cloud_hass, aioclient_mock, mock_write): + """Test exception in cloud while logging in.""" + aioclient_mock.post(url('o/token/'), status=500) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login_client_error(cloud_hass, aioclient_mock, mock_write): + """Test client error while logging in.""" + aioclient_mock.post(url('o/token/'), exc=aiohttp.ClientError) + with pytest.raises(cloud_api.UnknownError): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_async_login(cloud_hass, aioclient_mock, mock_write): + """Test logging in.""" + aioclient_mock.post(url('o/token/'), json={ + 'expires_in': 10 + }) + now = dt_util.utcnow() + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + yield from cloud_api.async_login(cloud_hass, 'user', 'pass') + + assert len(mock_write.mock_calls) == 1 + result_hass, result_data = mock_write.mock_calls[0][1] + assert result_hass is cloud_hass + assert result_data == { + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + + +@asyncio.coroutine +def test_load_auth_with_no_stored_auth(cloud_hass, mock_read): + """Test loading authentication with no stored auth.""" + mock_read.return_value = None + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_timeout_during_verification(cloud_hass, mock_read): + """Test loading authentication with timeout during verification.""" + mock_read.return_value = MOCK_AUTH + + with patch.object(cloud_api.Cloud, 'async_refresh_account_info', + side_effect=asyncio.TimeoutError): + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_verification_failed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with verify request getting 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_401(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 401.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=401) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_500(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh needed which gets 500.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), status=500) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_timeout(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + aioclient_mock.post(url('o/token/'), exc=asyncio.TimeoutError) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + + +@asyncio.coroutine +def test_load_auth_token_refresh_needed_succeeds(cloud_hass, mock_read, + aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), status=403) + + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh: + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is None + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_load_auth_token(cloud_hass, mock_read, aioclient_mock): + """Test loading authentication with refresh timing out.""" + mock_read.return_value = MOCK_AUTH + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + + result = yield from cloud_api.async_load_auth(cloud_hass) + + assert result is not None + assert result.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + assert result.auth == MOCK_AUTH + + +def test_cloud_properties(): + """Test Cloud class properties.""" + cloud = cloud_api.Cloud(None, MOCK_AUTH) + assert cloud.access_token == MOCK_AUTH['access_token'] + assert cloud.refresh_token == MOCK_AUTH['refresh_token'] + + +@asyncio.coroutine +def test_cloud_refresh_account_info(cloud_hass, aioclient_mock): + """Test refreshing account info.""" + aioclient_mock.get(url('account.json'), json={ + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + }) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert result + assert cloud.account == { + 'first_name': 'Paulus', + 'last_name': 'Schoutsen' + } + + +@asyncio.coroutine +def test_cloud_refresh_account_info_500(cloud_hass, aioclient_mock): + """Test refreshing account info and getting 500.""" + aioclient_mock.get(url('account.json'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + assert cloud.account is None + result = yield from cloud.async_refresh_account_info() + assert not result + assert cloud.account is None + + +@asyncio.coroutine +def test_cloud_refresh_token(cloud_hass, aioclient_mock, mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), json={ + 'access_token': 'refreshed', + 'expires_in': 10 + }) + now = dt_util.utcnow() + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch('homeassistant.components.cloud.cloud_api.utcnow', + return_value=now): + result = yield from cloud.async_refresh_access_token() + assert result + assert cloud.auth == { + 'access_token': 'refreshed', + 'expires_in': 10, + 'expires_at': (now + timedelta(seconds=10)).isoformat() + } + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data == cloud.auth + + +@asyncio.coroutine +def test_cloud_refresh_token_unknown_error(cloud_hass, aioclient_mock, + mock_write): + """Test refreshing access token.""" + aioclient_mock.post(url('o/token/'), status=500) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + result = yield from cloud.async_refresh_access_token() + assert not result + assert cloud.auth == MOCK_AUTH + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token(cloud_hass, aioclient_mock, mock_write): + """Test revoking access token.""" + aioclient_mock.post(url('o/revoke_token/')) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + yield from cloud.async_revoke_access_token() + assert cloud.auth is None + assert len(mock_write.mock_calls) == 1 + write_hass, write_data = mock_write.mock_calls[0][1] + assert write_hass is cloud_hass + assert write_data is None + + +@asyncio.coroutine +def test_cloud_revoke_token_invalid_client_creds(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), status=401) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_revoke_token_request_error(cloud_hass, aioclient_mock, + mock_write): + """Test revoking access token with invalid client credentials.""" + aioclient_mock.post(url('o/revoke_token/'), exc=aiohttp.ClientError) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with pytest.raises(cloud_api.UnknownError): + yield from cloud.async_revoke_access_token() + assert cloud.auth is not None + assert len(mock_write.mock_calls) == 0 + + +@asyncio.coroutine +def test_cloud_request(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), json={'hello': 'world'}) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 200 + data = yield from request.json() + assert data == {'hello': 'world'} + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_fail(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(False)) as mock_refresh: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + + +@asyncio.coroutine +def test_cloud_request_requiring_refresh_success(cloud_hass, aioclient_mock): + """Test making request to the cloud.""" + aioclient_mock.post(url('some_endpoint'), status=403) + cloud = cloud_api.Cloud(cloud_hass, MOCK_AUTH) + with patch.object(cloud_api.Cloud, 'async_refresh_access_token', + return_value=mock_coro(True)) as mock_refresh, \ + patch.object(cloud_api.Cloud, 'async_refresh_account_info', + return_value=mock_coro()) as mock_account_info: + request = yield from cloud.async_request('post', 'some_endpoint') + assert request.status == 403 + assert len(mock_refresh.mock_calls) == 1 + assert len(mock_account_info.mock_calls) == 1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py new file mode 100644 index 00000000000..99e73461bc1 --- /dev/null +++ b/tests/components/cloud/test_http_api.py @@ -0,0 +1,157 @@ +"""Tests for the HTTP API for the cloud component.""" +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.cloud import DOMAIN, cloud_api + +from tests.common import mock_coro + + +@pytest.fixture +def cloud_client(hass, test_client): + """Fixture that can fetch from the cloud client.""" + hass.loop.run_until_complete(async_setup_component(hass, 'cloud', { + 'cloud': { + 'mode': 'development' + } + })) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + +@asyncio.coroutine +def test_account_view_no_account(cloud_client): + """Test fetching account if no account available.""" + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 400 + + +@asyncio.coroutine +def test_account_view(hass, cloud_client): + """Test fetching account if no account available.""" + cloud = MagicMock(account={'test': 'account'}) + hass.data[DOMAIN]['cloud'] = cloud + req = yield from cloud_client.get('/api/cloud/account') + assert req.status == 200 + result = yield from req.json() + assert result == {'test': 'account'} + + +@asyncio.coroutine +def test_login_view(hass, cloud_client): + """Test logging in.""" + cloud = MagicMock(account={'test': 'account'}) + cloud.async_refresh_account_info.return_value = mock_coro(None) + + with patch.object(cloud_api, 'async_login', + MagicMock(return_value=mock_coro(cloud))): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 200 + + result = yield from req.json() + assert result == {'test': 'account'} + assert hass.data[DOMAIN]['cloud'] is cloud + + +@asyncio.coroutine +def test_login_view_invalid_json(hass, cloud_client): + """Try logging in with invalid JSON.""" + req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_schema(hass, cloud_client): + """Try logging in with invalid schema.""" + req = yield from cloud_client.post('/api/cloud/login', json={ + 'invalid': 'schema' + }) + assert req.status == 400 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_request_timeout(hass, cloud_client): + """Test request timeout while trying to log in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=asyncio.TimeoutError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 502 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_invalid_credentials(hass, cloud_client): + """Test logging in with invalid credentials.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.Unauthenticated)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 401 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_login_view_unknown_error(hass, cloud_client): + """Test unknown error while logging in.""" + with patch.object(cloud_api, 'async_login', + MagicMock(side_effect=cloud_api.UnknownError)): + req = yield from cloud_client.post('/api/cloud/login', json={ + 'username': 'my_username', + 'password': 'my_password' + }) + + assert req.status == 500 + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view(hass, cloud_client): + """Test logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.return_value = mock_coro(None) + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 200 + data = yield from req.json() + assert data == {'result': 'ok'} + assert 'cloud' not in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_request_timeout(hass, cloud_client): + """Test timeout while logging out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = asyncio.TimeoutError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] + + +@asyncio.coroutine +def test_logout_view_unknown_error(hass, cloud_client): + """Test unknown error while loggin out.""" + cloud = MagicMock() + cloud.async_revoke_access_token.side_effect = cloud_api.UnknownError + hass.data[DOMAIN]['cloud'] = cloud + + req = yield from cloud_client.post('/api/cloud/logout') + assert req.status == 502 + assert 'cloud' in hass.data[DOMAIN] diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py new file mode 100644 index 00000000000..f12774c25d9 --- /dev/null +++ b/tests/components/config/test_customize.py @@ -0,0 +1,118 @@ +"""Test Customize config panel.""" +import asyncio +import json +from unittest.mock import patch + +from homeassistant.bootstrap import async_setup_component +from homeassistant.components import config +from homeassistant.config import DATA_CUSTOMIZE + + +@asyncio.coroutine +def test_get_entity(hass, test_client): + """Test getting entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + def mock_read(path): + """Mock reading data.""" + return { + 'hello.beer': { + 'free': 'beer', + }, + 'other.entity': { + 'do': 'something', + }, + } + hass.data[DATA_CUSTOMIZE] = {'hello.beer': {'cold': 'beer'}} + with patch('homeassistant.components.config._read', mock_read): + resp = yield from client.get( + '/api/config/customize/config/hello.beer') + + assert resp.status == 200 + result = yield from resp.json() + + assert result == {'local': {'free': 'beer'}, 'global': {'cold': 'beer'}} + + +@asyncio.coroutine +def test_update_entity(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + orig_data = { + 'hello.beer': { + 'ignored': True, + }, + 'other.entity': { + 'polling_intensity': 2, + }, + } + + def mock_read(path): + """Mock reading data.""" + return orig_data + + written = [] + + def mock_write(path, data): + """Mock writing data.""" + written.append(data) + + hass.states.async_set('hello.world', 'state', {'a': 'b'}) + with patch('homeassistant.components.config._read', mock_read), \ + patch('homeassistant.components.config._write', mock_write): + resp = yield from client.post( + '/api/config/customize/config/hello.world', data=json.dumps({ + 'name': 'Beer', + 'entities': ['light.top', 'light.bottom'], + })) + + assert resp.status == 200 + result = yield from resp.json() + assert result == {'result': 'ok'} + + state = hass.states.get('hello.world') + assert state.state == 'state' + assert dict(state.attributes) == { + 'a': 'b', 'name': 'Beer', 'entities': ['light.top', 'light.bottom']} + + orig_data['hello.world']['name'] = 'Beer' + orig_data['hello.world']['entities'] = ['light.top', 'light.bottom'] + + assert written[0] == orig_data + + +@asyncio.coroutine +def test_update_entity_invalid_key(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/not_entity', data=json.dumps({ + 'name': 'YO', + })) + + assert resp.status == 400 + + +@asyncio.coroutine +def test_update_entity_invalid_json(hass, test_client): + """Test updating entity.""" + with patch.object(config, 'SECTIONS', ['customize']): + yield from async_setup_component(hass, 'config', {}) + + client = yield from test_client(hass.http.app) + + resp = yield from client.post( + '/api/config/customize/config/hello.beer', data='not json') + + assert resp.status == 400 diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d572791168c..d40c1518ffa 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -1,5 +1,6 @@ """Test the automatic device tracker platform.""" import asyncio +from datetime import datetime import logging from unittest.mock import patch, MagicMock import aioautomatic @@ -71,10 +72,12 @@ def test_valid_credentials( vehicle.display_name = 'mock_display_name' vehicle.fuel_level_percent = 45.6 vehicle.latest_location = None + vehicle.updated_at = datetime(2017, 8, 13, 1, 2, 3) trip.end_location.lat = 45.567 trip.end_location.lon = 34.345 trip.end_location.accuracy_m = 5.6 + trip.ended_at = datetime(2017, 8, 13, 1, 2, 4) @asyncio.coroutine def get_session(*args, **kwargs): diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/device_tracker/test_geofency.py new file mode 100644 index 00000000000..e8aa44cb0e5 --- /dev/null +++ b/tests/components/device_tracker/test_geofency.py @@ -0,0 +1,230 @@ +"""The tests for the Geofency device tracker platform.""" +# pylint: disable=redefined-outer-name +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.components import zone +import homeassistant.components.device_tracker as device_tracker +from homeassistant.components.device_tracker.geofency import ( + CONF_MOBILE_BEACONS, URL) +from homeassistant.const import ( + CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + STATE_NOT_HOME) +from homeassistant.setup import async_setup_component +from homeassistant.util import slugify + +HOME_LATITUDE = 37.239622 +HOME_LONGITUDE = -115.815811 + +NOT_HOME_LATITUDE = 37.239394 +NOT_HOME_LONGITUDE = -115.763283 + +GPS_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +GPS_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_HOME = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Home', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + +BEACON_ENTER_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '1' +} + +BEACON_EXIT_CAR = { + 'latitude': NOT_HOME_LATITUDE, + 'longitude': NOT_HOME_LONGITUDE, + 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556', + 'minor': '36138', + 'major': '8629', + 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416', + 'name': 'Car 1', + 'radius': 100, + 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205', + 'date': '2017-08-19T10:53:53Z', + 'address': 'Testing Trail 1', + 'entry': '0' +} + + +@pytest.fixture +def geofency_client(loop, hass, test_client): + """Geofency mock client.""" + assert loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'geofency', + CONF_MOBILE_BEACONS: ['Car 1'] + }})) + + with patch('homeassistant.components.device_tracker.update_config'): + yield loop.run_until_complete(test_client(hass.http.app)) + + +@pytest.fixture(autouse=True) +def setup_zones(loop, hass): + """Setup Zone config in HA.""" + assert loop.run_until_complete(async_setup_component( + hass, zone.DOMAIN, { + 'zone': { + 'name': 'Home', + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'radius': 100, + }})) + + +@asyncio.coroutine +def test_data_validation(geofency_client): + """Test data validation.""" + # No data + req = yield from geofency_client.post(URL) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + missing_attributes = ['address', 'device', + 'entry', 'latitude', 'longitude', 'name'] + + # missing attributes + for attribute in missing_attributes: + copy = GPS_ENTER_HOME.copy() + del copy[attribute] + req = yield from geofency_client.post(URL, data=copy) + assert req.status == HTTP_UNPROCESSABLE_ENTITY + + +@asyncio.coroutine +def test_gps_enter_and_exit_home(hass, geofency_client): + """Test GPS based zone enter and exit.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_ENTER_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify(GPS_EXIT_HOME['device']) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_home(hass, geofency_client): + """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" + # Enter the Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + +@asyncio.coroutine +def test_beacon_enter_and_exit_car(hass, geofency_client): + """Test use of mobile iBeacon.""" + # Enter the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Exit the Car away from Home zone + req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_NOT_HOME == state_name + + # Enter the Car in the Home zone + data = BEACON_ENTER_CAR.copy() + data['latitude'] = HOME_LATITUDE + data['longitude'] = HOME_LONGITUDE + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name + + # Exit the Car in the Home zone + req = yield from geofency_client.post(URL, data=data) + assert req.status == HTTP_OK + device_name = slugify("beacon_{}".format(data['name'])) + state_name = hass.states.get('{}.{}'.format( + 'device_tracker', device_name)).state + assert STATE_HOME == state_name diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 97375aa6b13..e111fc3aa49 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -123,6 +123,20 @@ light: payload_on: "on" payload_off: "off" +config for RGB Version with RGB command template: + +light: + platform: mqtt + name: "Office Light RGB" + state_topic: "office/rgb1/light/status" + command_topic: "office/rgb1/light/switch" + rgb_state_topic: "office/rgb1/rgb/status" + rgb_command_topic: "office/rgb1/rgb/set" + rgb_command_template: "{{ '#%02x%02x%02x' | format(red, green, blue)}}" + qos: 0 + payload_on: "on" + payload_off: "off" + """ import unittest from unittest import mock @@ -512,6 +526,38 @@ class TestLightMQTT(unittest.TestCase): self.assertEqual(80, state.attributes['white_value']) self.assertEqual((0.123, 0.123), state.attributes['xy_color']) + def test_sending_mqtt_rgb_command_with_template(self): + """Test the sending of RGB command with template.""" + config = {light.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'rgb_command_topic': 'test_light_rgb/rgb/set', + 'rgb_command_template': '{{ "#%02x%02x%02x" | ' + 'format(red, green, blue)}}', + 'payload_on': 'on', + 'payload_off': 'off', + 'qos': 0 + }} + + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_OFF, state.state) + + light.turn_on(self.hass, 'light.test', rgb_color=[255, 255, 255]) + self.hass.block_till_done() + + self.mock_publish().async_publish.assert_has_calls([ + mock.call('test_light_rgb/set', 'on', 0, False), + mock.call('test_light_rgb/rgb/set', '#ffffff', 0, False), + ], any_order=True) + + state = self.hass.states.get('light.test') + self.assertEqual(STATE_ON, state.state) + self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + def test_show_brightness_if_only_command_topic(self): """Test the brightness if only a command topic is present.""" config = {light.DOMAIN: { diff --git a/tests/components/sensor/test_season.py b/tests/components/sensor/test_season.py new file mode 100644 index 00000000000..10e147bcff9 --- /dev/null +++ b/tests/components/sensor/test_season.py @@ -0,0 +1,183 @@ +"""The tests for the Season sensor platform.""" +# pylint: disable=protected-access +import unittest +from datetime import datetime + +import homeassistant.components.sensor.season as season + +from tests.common import get_test_home_assistant + + +# pylint: disable=invalid-name +class TestSeason(unittest.TestCase): + """Test the season platform.""" + + DEVICE = None + CONFIG_ASTRONOMICAL = {'type': 'astronomical'} + CONFIG_METEOROLOGICAL = {'type': 'meteorological'} + + def add_devices(self, devices): + """Mock add devices.""" + for device in devices: + self.DEVICE = device + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_season_should_be_summer_northern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_northern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(summer_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_northern_astonomical(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_northern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(autumn_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_winter_northern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_northern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(winter_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_northern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_northern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(spring_day, season.NORTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_winter_southern_astonomical(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_winter_southern_meteorological(self): + """Test that season should be winter.""" + # A known day in winter + winter_day = datetime(2017, 8, 13, 0, 0) + current_season = season.get_season(winter_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_WINTER, + current_season) + + def test_season_should_be_spring_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 23, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_spring_southern_meteorological(self): + """Test that season should be spring.""" + # A known day in spring + spring_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(spring_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SPRING, + current_season) + + def test_season_should_be_summer_southern_astonomical(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 25, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_summer_southern_meteorological(self): + """Test that season should be summer.""" + # A known day in summer + summer_day = datetime(2017, 12, 3, 0, 0) + current_season = season.get_season(summer_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_SUMMER, + current_season) + + def test_season_should_be_autumn_southern_astonomical(self): + """Test that season should be spring.""" + # A known day in spring + autumn_day = datetime(2017, 4, 1, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_ASTRONOMICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_season_should_be_autumn_southern_meteorological(self): + """Test that season should be autumn.""" + # A known day in autumn + autumn_day = datetime(2017, 3, 3, 0, 0) + current_season = season.get_season(autumn_day, season.SOUTHERN, + season.TYPE_METEOROLOGICAL) + self.assertEqual(season.STATE_AUTUMN, + current_season) + + def test_on_equator_results_in_none(self): + """Test that season should be unknown.""" + # A known day in summer if astronomical and northern + summer_day = datetime(2017, 9, 3, 0, 0) + current_season = season.get_season(summer_day, + season.EQUATOR, + season.TYPE_ASTRONOMICAL) + self.assertEqual(None, current_season) diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index b71b96e1400..9e2050e850c 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -549,6 +549,25 @@ def mocked_requests_get(*args, **kwargs): "totalSpace": 499738734592 } ], 200) + elif 'api/system/status' in url: + return MockResponse({ + "version": "2.0.0.1121", + "buildTime": "2014-02-08T20:49:36.5560392Z", + "isDebug": "false", + "isProduction": "true", + "isAdmin": "true", + "isUserInteractive": "false", + "startupPath": "C:\\ProgramData\\NzbDrone\\bin", + "appData": "C:\\ProgramData\\NzbDrone", + "osVersion": "6.2.9200.0", + "isMono": "false", + "isLinux": "false", + "isWindows": "true", + "branch": "develop", + "authentication": "false", + "startOfWeek": 0, + "urlBase": "" + }, 200) else: return MockResponse({ "error": "Unauthorized" @@ -794,6 +813,31 @@ class TestSonarrSetup(unittest.TestCase): device.device_state_attributes["Bob's Burgers"] ) + @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) + def test_system_status(self, req_mock): + """Test getting system status.""" + config = { + 'platform': 'sonarr', + 'api_key': 'foo', + 'days': '2', + 'unit': 'GB', + "include_paths": [ + '/data' + ], + 'monitored_conditions': [ + 'status' + ] + } + sonarr.setup_platform(self.hass, config, self.add_devices, None) + for device in self.DEVICES: + device.update() + self.assertEqual('2.0.0.1121', device.state) + self.assertEqual('mdi:information', device.icon) + self.assertEqual('Sonarr Status', device.name) + self.assertEqual( + '6.2.9200.0', + device.device_state_attributes['osVersion']) + @pytest.mark.skip @unittest.mock.patch('requests.get', side_effect=mocked_requests_get) def test_ssl(self, req_mock): diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 753b18f137f..ba71c6e3993 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -4,7 +4,10 @@ import statistics from homeassistant.setup import setup_component from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.util import dt as dt_util from tests.common import get_test_home_assistant +from unittest.mock import patch +from datetime import datetime, timedelta class TestStatisticsSensor(unittest.TestCase): @@ -100,3 +103,35 @@ class TestStatisticsSensor(unittest.TestCase): self.assertEqual(3.8, state.attributes.get('min_value')) self.assertEqual(14, state.attributes.get('max_value')) + + def test_max_age(self): + """Test value deprecation.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now): + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'max_age': {'minutes': 3} + } + }) + + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value one minute later + mock_data['return_time'] += timedelta(minutes=1) + + state = self.hass.states.get('sensor.test_mean') + + self.assertEqual(6, state.attributes.get('min_value')) + self.assertEqual(14, state.attributes.get('max_value')) diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index d529e8c3f56..0d2a486cb4f 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -347,6 +347,261 @@ class TestSwitchFlux(unittest.TestCase): self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154) self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397]) + def test_flux_before_sunrise_stop_next_day(self): + """Test the flux switch before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + + # pylint: disable=invalid-name + def test_flux_after_sunrise_before_sunset_stop_next_day(self): + """ + Test the flux switch after sunrise and before sunset. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=8, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_before_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=23, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 126) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.574, 0.401]) + + # pylint: disable=invalid-name + def test_flux_after_sunset_after_midnight_stop_next_day(self): + """Test the flux switch after sunset and before stop. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=00, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 122) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.586, 0.397]) + + # pylint: disable=invalid-name + def test_flux_after_stop_before_sunrise_stop_next_day(self): + """Test the flux switch after stop and before sunrise. + + This test has the stop_time on the next day (after midnight). + """ + platform = loader.get_component('light.test') + platform.init() + self.assertTrue( + setup_component(self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}})) + + dev1 = platform.DEVICES[0] + + # Verify initial state of light + state = self.hass.states.get(dev1.entity_id) + self.assertEqual(STATE_ON, state.state) + self.assertIsNone(state.attributes.get('xy_color')) + self.assertIsNone(state.attributes.get('brightness')) + + test_time = dt_util.now().replace(hour=2, minute=30, second=0) + sunset_time = test_time.replace(hour=17, minute=0, second=0) + sunrise_time = test_time.replace(hour=5, minute=0, second=0) + + def event_date(hass, event, now=None): + if event == 'sunrise': + return sunrise_time + else: + return sunset_time + + with patch('homeassistant.util.dt.now', return_value=test_time): + with patch('homeassistant.helpers.sun.get_astral_event_date', + side_effect=event_date): + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'flux', + 'name': 'flux', + 'lights': [dev1.entity_id], + 'stop_time': '01:00' + } + }) + turn_on_calls = mock_service( + self.hass, light.DOMAIN, SERVICE_TURN_ON) + switch.turn_on(self.hass, 'switch.flux') + self.hass.block_till_done() + fire_time_changed(self.hass, test_time) + self.hass.block_till_done() + call = turn_on_calls[-1] + self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119) + self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395]) + # pylint: disable=invalid-name def test_flux_with_custom_colortemps(self): """Test the flux with custom start and stop colortemps.""" diff --git a/tests/components/test_counter.py b/tests/components/test_counter.py new file mode 100644 index 00000000000..8dc04f0e76a --- /dev/null +++ b/tests/components/test_counter.py @@ -0,0 +1,204 @@ +"""The tests for the counter component.""" +# pylint: disable=protected-access +import asyncio +import unittest +import logging + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.counter import ( + DOMAIN, decrement, increment, reset, CONF_INITIAL, CONF_STEP, CONF_NAME, + CONF_ICON) +from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) + +from tests.common import (get_test_home_assistant, mock_restore_cache) + +_LOGGER = logging.getLogger(__name__) + + +class TestCounter(unittest.TestCase): + """Test the counter component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_methods(self): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(2, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(1, int(state.state)) + + reset(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(0, int(state.state)) + + def test_methods_with_config(self): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + + entity_id = 'counter.test' + + state = self.hass.states.get(entity_id) + self.assertEqual(10, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + increment(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(20, int(state.state)) + + decrement(self.hass, entity_id) + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual(15, int(state.state)) + + def test_config_options(self): + """Test configuration options.""" + count_start = len(self.hass.states.entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_STEP: 5, + } + } + } + + assert setup_component(self.hass, 'counter', config) + self.hass.block_till_done() + + _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + + self.assertEqual(count_start + 2, len(self.hass.states.entity_ids())) + self.hass.block_till_done() + + state_1 = self.hass.states.get('counter.test_1') + state_2 = self.hass.states.get('counter.test_2') + + self.assertIsNotNone(state_1) + self.assertIsNotNone(state_2) + + self.assertEqual(0, int(state_1.state)) + self.assertNotIn(ATTR_ICON, state_1.attributes) + self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes) + + self.assertEqual(10, int(state_2.state)) + self.assertEqual('Hello World', + state_2.attributes.get(ATTR_FRIENDLY_NAME)) + self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON)) + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('counter.test1', '11'), + State('counter.test2', '-22'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': {}, + 'test2': { + CONF_INITIAL: 10, + }, + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 + + state = hass.states.get('counter.test2') + assert state + assert int(state.state) == 10 + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test1': { + CONF_STEP: 5, + } + }}) + + state = hass.states.get('counter.test1') + assert state + assert int(state.state) == 0 diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py index 3682e0a2c14..fdd33b99d2b 100644 --- a/tests/components/test_frontend.py +++ b/tests/components/test_frontend.py @@ -6,7 +6,8 @@ from unittest.mock import patch import pytest from homeassistant.setup import async_setup_component -from homeassistant.components.frontend import DOMAIN, ATTR_THEMES +from homeassistant.components.frontend import ( + DOMAIN, ATTR_THEMES, ATTR_EXTRA_HTML_URL, DATA_PANELS, register_panel) @pytest.fixture @@ -30,6 +31,16 @@ def mock_http_client_with_themes(hass, test_client): return hass.loop.run_until_complete(test_client(hass.http.app)) +@pytest.fixture +def mock_http_client_with_urls(hass, test_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { + DOMAIN: { + ATTR_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"] + }})) + return hass.loop.run_until_complete(test_client(hass.http.app)) + + @asyncio.coroutine def test_frontend_and_static(mock_http_client): """Test if we can get the frontend.""" @@ -143,3 +154,29 @@ def test_missing_themes(mock_http_client): json = yield from resp.json() assert json['default_theme'] == 'default' assert json['themes'] == {} + + +@asyncio.coroutine +def test_extra_urls(mock_http_client_with_urls): + """Test that extra urls are loaded.""" + resp = yield from mock_http_client_with_urls.get('/states') + assert resp.status == 200 + text = yield from resp.text() + assert text.find('href=\'https://domain.com/my_extra_url.html\'') >= 0 + + +@asyncio.coroutine +def test_panel_without_path(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', 'nonexistant_file') + assert hass.data[DATA_PANELS] == {} + + +@asyncio.coroutine +def test_panel_with_url(hass): + """Test panel registration without file path.""" + register_panel(hass, 'test_component', None, url='some_url') + assert hass.data[DATA_PANELS] == { + 'test_component': {'component_name': 'test_component', + 'url': 'some_url', + 'url_path': 'test_component'}} diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py new file mode 100755 index 00000000000..be22e1122ea --- /dev/null +++ b/tests/components/test_input_text.py @@ -0,0 +1,147 @@ +"""The tests for the Input text component.""" +# pylint: disable=protected-access +import asyncio +import unittest + +from homeassistant.core import CoreState, State +from homeassistant.setup import setup_component, async_setup_component +from homeassistant.components.input_text import (DOMAIN, set_value) + +from tests.common import get_test_home_assistant, mock_restore_cache + + +class TestInputText(unittest.TestCase): + """Test the input slider component.""" + + # pylint: disable=invalid-name + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + # pylint: disable=invalid-name + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_config(self): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + self.assertFalse( + setup_component(self.hass, DOMAIN, {DOMAIN: cfg})) + + def test_set_value(self): + """Test set_value method.""" + self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }})) + entity_id = 'input_text.test_1' + + state = self.hass.states.get(entity_id) + self.assertEqual('test', str(state.state)) + + set_value(self.hass, entity_id, 'testing') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + set_value(self.hass, entity_id, 'testing too long') + self.hass.block_till_done() + + state = self.hass.states.get(entity_id) + self.assertEqual('testing', str(state.state)) + + +@asyncio.coroutine +def test_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'test'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 10, + }, + 'b2': { + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'unknown' + + +@asyncio.coroutine +def test_initial_state_overrules_restore_state(hass): + """Ensure states are restored on startup.""" + mock_restore_cache(hass, ( + State('input_text.b1', 'testing'), + State('input_text.b2', 'testing too long'), + )) + + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + 'b2': { + 'initial': 'test', + 'min': 0, + 'max': 10, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'test' + + state = hass.states.get('input_text.b2') + assert state + assert str(state.state) == 'test' + + +@asyncio.coroutine +def test_no_initial_state_and_no_restore_state(hass): + """Ensure that entity is create without initial and restore feature.""" + hass.state = CoreState.starting + + yield from async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'b1': { + 'min': 0, + 'max': 100, + }, + }}) + + state = hass.states.get('input_text.b1') + assert state + assert str(state.state) == 'unknown' diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index b7148dd982e..32351234ad3 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -117,6 +117,60 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id +@asyncio.coroutine +def test_central_scene_activated(hass, mock_openzwave): + """Test central scene activated event.""" + mock_receivers = [] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal == mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED: + mock_receivers.append(receiver) + + node = mock_zwave.MockNode(node_id=11) + + with patch('pydispatch.dispatcher.connect', new=mock_connect): + entity = node_entity.ZWaveNodeEntity(node, mock_openzwave, True) + + assert len(mock_receivers) == 1 + + events = [] + + def listener(event): + events.append(event) + + hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener) + + # Test event before entity added to hass + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + assert len(events) == 0 + + # Add entity to hass + entity.hass = hass + entity.entity_id = 'zwave.mock_node' + + scene_id = 1 + scene_data = 3 + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_CENTRAL_SCENE, + index=scene_id, + data=scene_data) + hass.async_add_job(mock_receivers[0], node, value) + yield from hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" + assert events[0].data[const.ATTR_NODE_ID] == 11 + assert events[0].data[const.ATTR_SCENE_ID] == scene_id + assert events[0].data[const.ATTR_SCENE_DATA] == scene_data + + @pytest.mark.usefixtures('mock_openzwave') class TestZWaveNodeEntity(unittest.TestCase): """Class to test ZWaveNodeEntity.""" diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json index 6bbcc0ee3f9..7c9e0b07405 100644 --- a/tests/fixtures/ring_ding_active.json +++ b/tests/fixtures/ring_ding_active.json @@ -2,7 +2,7 @@ "audio_jitter_buffer_ms": 0, "device_kind": "lpd_v1", "doorbot_description": "Front Door", - "doorbot_id": 12345, + "doorbot_id": 987652, "expires_in": 180, "id": 123456789, "id_str": "123456789", diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 37ff8ba297e..9c325df181e 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -17,6 +17,7 @@ from homeassistant.helpers.event import ( track_state_change, track_time_interval, track_template, + track_same_state, track_sunrise, track_sunset, ) @@ -24,7 +25,7 @@ from homeassistant.helpers.template import Template from homeassistant.components import sun import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, fire_time_changed from unittest.mock import patch @@ -262,6 +263,111 @@ class TestEventHelpers(unittest.TestCase): self.assertEqual(2, len(wildcard_runs)) self.assertEqual(2, len(wildercard_runs)) + def test_track_same_state_simple_trigger(self): + """Test track_same_change with trigger simple.""" + thread_runs = [] + callback_runs = [] + coroutine_runs = [] + period = timedelta(minutes=1) + + def thread_run_callback(): + thread_runs.append(1) + + track_same_state( + self.hass, 'on', period, thread_run_callback, + entity_ids='light.Bowl') + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + @asyncio.coroutine + def coroutine_run_callback(): + coroutine_runs.append(1) + + track_same_state( + self.hass, 'on', period, coroutine_run_callback) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(thread_runs)) + self.assertEqual(0, len(callback_runs)) + self.assertEqual(0, len(coroutine_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(thread_runs)) + self.assertEqual(1, len(callback_runs)) + self.assertEqual(1, len(coroutine_runs)) + + def test_track_same_state_simple_no_trigger(self): + """Test track_same_change with no trigger.""" + callback_runs = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl') + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # Change state on state machine + self.hass.states.set("light.Bowl", "off") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + + def test_track_same_state_simple_trigger_check_funct(self): + """Test track_same_change with trigger and check funct.""" + callback_runs = [] + check_func = [] + period = timedelta(minutes=1) + + @ha.callback + def callback_run_callback(): + callback_runs.append(1) + + @ha.callback + def async_check_func(entity, from_s, to_s): + check_func.append((entity, from_s, to_s)) + return 'on' + + track_same_state( + self.hass, 'on', period, callback_run_callback, + entity_ids='light.Bowl', async_check_func=async_check_func) + + # Adding state to state machine + self.hass.states.set("light.Bowl", "on") + self.hass.block_till_done() + self.assertEqual(0, len(callback_runs)) + self.assertEqual('on', check_func[-1][2].state) + self.assertEqual('light.bowl', check_func[-1][0]) + + # change time to track and see if they trigger + future = dt_util.utcnow() + period + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(1, len(callback_runs)) + def test_track_time_interval(self): """Test tracking time interval.""" specific_runs = [] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 6a2a77f5c71..e668bd5b6cd 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -782,3 +782,17 @@ def test_state_with_unit(hass): hass) assert tpl.async_render() == '' + + +@asyncio.coroutine +def test_length_of_states(hass): + """Test fetching the length of states.""" + hass.states.async_set('sensor.test', '23') + hass.states.async_set('sensor.test2', 'wow') + hass.states.async_set('climate.test2', 'cooling') + + tpl = template.Template('{{ states | length }}', hass) + assert tpl.async_render() == '3' + + tpl = template.Template('{{ states.sensor | length }}', hass) + assert tpl.async_render() == '2' diff --git a/tests/test_config.py b/tests/test_config.py index 8c889979a82..1cb5e00bee9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,10 @@ from homeassistant.components.config.group import ( CONFIG_PATH as GROUP_CONFIG_PATH) from homeassistant.components.config.automation import ( CONFIG_PATH as AUTOMATIONS_CONFIG_PATH) +from homeassistant.components.config.script import ( + CONFIG_PATH as SCRIPTS_CONFIG_PATH) +from homeassistant.components.config.customize import ( + CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) from tests.common import ( get_test_config_dir, get_test_home_assistant, mock_coro) @@ -31,6 +35,8 @@ YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH) +SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH) +CUSTOMIZE_PATH = os.path.join(CONFIG_DIR, CUSTOMIZE_CONFIG_PATH) ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE @@ -65,8 +71,15 @@ class TestConfig(unittest.TestCase): if os.path.isfile(AUTOMATIONS_PATH): os.remove(AUTOMATIONS_PATH) + if os.path.isfile(SCRIPTS_PATH): + os.remove(SCRIPTS_PATH) + + if os.path.isfile(CUSTOMIZE_PATH): + os.remove(CUSTOMIZE_PATH) + self.hass.stop() + # pylint: disable=no-self-use def test_create_default_config(self): """Test creation of default config.""" config_util.create_default_config(CONFIG_DIR, False) @@ -75,6 +88,7 @@ class TestConfig(unittest.TestCase): assert os.path.isfile(VERSION_PATH) assert os.path.isfile(GROUP_PATH) assert os.path.isfile(AUTOMATIONS_PATH) + assert os.path.isfile(CUSTOMIZE_PATH) def test_find_config_file_yaml(self): """Test if it finds a YAML config file.""" @@ -169,7 +183,8 @@ class TestConfig(unittest.TestCase): CONF_ELEVATION: 101, CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, CONF_NAME: 'Home', - CONF_TIME_ZONE: 'America/Los_Angeles' + CONF_TIME_ZONE: 'America/Los_Angeles', + CONF_CUSTOMIZE: OrderedDict(), } assert expected_values == ha_conf @@ -334,11 +349,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return True with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version @@ -359,11 +375,12 @@ class TestConfig(unittest.TestCase): mock_open = mock.mock_open() - def mock_isfile(filename): + def _mock_isfile(filename): return False with mock.patch('homeassistant.config.open', mock_open, create=True), \ - mock.patch('homeassistant.config.os.path.isfile', mock_isfile): + mock.patch( + 'homeassistant.config.os.path.isfile', _mock_isfile): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 0af5321c65f..ccd71e55d16 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -201,6 +201,7 @@ def mock_aiohttp_client(): with mock.patch('aiohttp.ClientSession') as mock_session: instance = mock_session() + instance.request = mocker.match_request for method in ('get', 'post', 'put', 'options', 'delete'): setattr(instance, method,