diff --git a/.coveragerc b/.coveragerc index 2d1bff462b9..c4051af5136 100644 --- a/.coveragerc +++ b/.coveragerc @@ -172,6 +172,9 @@ omit = homeassistant/components/twilio.py homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py + + homeassistant/components/velbus.py + homeassistant/components/*/velbus.py homeassistant/components/velux.py homeassistant/components/*/velux.py @@ -193,6 +196,9 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py + homeassistant/components/xiaomi.py + homeassistant/components/*/xiaomi.py + homeassistant/components/zabbix.py homeassistant/components/*/zabbix.py @@ -208,6 +214,7 @@ omit = homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/concord232.py + homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py homeassistant/components/alarm_control_panel/simplisafe.py homeassistant/components/alarm_control_panel/totalconnect.py @@ -274,7 +281,6 @@ omit = homeassistant/components/device_tracker/tplink.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py - homeassistant/components/device_tracker/xiaomi.py homeassistant/components/downloader.py homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py @@ -303,6 +309,7 @@ omit = homeassistant/components/light/piglow.py homeassistant/components/light/sensehat.py homeassistant/components/light/tikteck.py + homeassistant/components/light/tplink.py homeassistant/components/light/tradfri.py homeassistant/components/light/x10.py homeassistant/components/light/yeelight.py diff --git a/README.rst b/README.rst index 66c9cedea74..039e8a922af 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -Home Assistant |Build Status| |Coverage Status| |Join the chat at https://gitter.im/home-assistant/home-assistant| |Join the dev chat at https://gitter.im/home-assistant/home-assistant/devs| -============================================================================================================================================================================================== +Home Assistant |Build Status| |Coverage Status| |Chat Status| +============================================================= Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. @@ -31,6 +31,8 @@ of a component, check the `Home Assistant help section Optional[core.HomeAssistant]: - """Try to configure Home Assistant from a config dict. + """Try to configure Home Assistant from a configuration dictionary. Dynamically loads required components and its dependencies. """ @@ -48,7 +49,8 @@ def from_config_dict(config: Dict[str, Any], if config_dir is not None: config_dir = os.path.abspath(config_dir) hass.config.config_dir = config_dir - mount_local_lib_path(config_dir) + hass.loop.run_until_complete( + async_mount_local_lib_path(config_dir, hass.loop)) # run task hass = hass.loop.run_until_complete( @@ -69,7 +71,7 @@ def async_from_config_dict(config: Dict[str, Any], skip_pip: bool=False, log_rotate_days: Any=None) \ -> Optional[core.HomeAssistant]: - """Try to configure Home Assistant from a config dict. + """Try to configure Home Assistant from a configuration dictionary. Dynamically loads required components and its dependencies. This method is a coroutine. @@ -90,8 +92,8 @@ def async_from_config_dict(config: Dict[str, Any], hass.config.skip_pip = skip_pip if skip_pip: - _LOGGER.warning('Skipping pip installation of required modules. ' - 'This may cause issues.') + _LOGGER.warning("Skipping pip installation of required modules. " + "This may cause issues") if not loader.PREPARED: yield from hass.async_add_job(loader.prepare, hass) @@ -116,13 +118,13 @@ def async_from_config_dict(config: Dict[str, Any], # pylint: disable=not-an-iterable res = yield from core_components.async_setup(hass, config) if not res: - _LOGGER.error('Home Assistant core failed to initialize. ' - 'Further initialization aborted.') + _LOGGER.error("Home Assistant core failed to initialize. " + "further initialization aborted") return hass yield from persistent_notification.async_setup(hass, config) - _LOGGER.info('Home Assistant core initialized') + _LOGGER.info("Home Assistant core initialized") # stage 1 for component in components: @@ -141,7 +143,7 @@ def async_from_config_dict(config: Dict[str, Any], yield from hass.async_block_till_done() stop = time() - _LOGGER.info('Home Assistant initialized in %.2fs', stop-start) + _LOGGER.info("Home Assistant initialized in %.2fs", stop-start) async_register_signal_handling(hass) return hass @@ -183,7 +185,7 @@ def async_from_config_file(config_path: str, # Set config dir to directory holding config file config_dir = os.path.abspath(os.path.dirname(config_path)) hass.config.config_dir = config_dir - yield from hass.async_add_job(mount_local_lib_path, config_dir) + yield from async_mount_local_lib_path(config_dir, hass.loop) async_enable_logging(hass, verbose, log_rotate_days) @@ -191,7 +193,7 @@ def async_from_config_file(config_path: str, config_dict = yield from hass.async_add_job( conf_util.load_yaml_config_file, config_path) except HomeAssistantError as err: - _LOGGER.error('Error loading %s: %s', config_path, err) + _LOGGER.error("Error loading %s: %s", config_path, err) return None finally: clear_secret_cache() @@ -276,11 +278,23 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False, def mount_local_lib_path(config_dir: str) -> str: + """Add local library to Python Path.""" + deps_dir = os.path.join(config_dir, 'deps') + lib_dir = get_user_site(deps_dir) + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) + return deps_dir + + +@asyncio.coroutine +def async_mount_local_lib_path(config_dir: str, + loop: asyncio.AbstractEventLoop) -> str: """Add local library to Python Path. - Async friendly. + This function is a coroutine. """ deps_dir = os.path.join(config_dir, 'deps') - if deps_dir not in sys.path: - sys.path.insert(0, os.path.join(config_dir, 'deps')) + lib_dir = yield from async_get_user_site(deps_dir, loop=loop) + if lib_dir not in sys.path: + sys.path.insert(0, lib_dir) return deps_dir diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index ecbb8036464..1d437d35da7 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -15,7 +15,6 @@ import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.service import extract_entity_ids -from homeassistant.loader import get_component from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART, @@ -33,25 +32,27 @@ def is_on(hass, entity_id=None): If there is no entity id given we will check all. """ if entity_id: - group = get_component('group') - - entity_ids = group.expand_entity_ids(hass, [entity_id]) + entity_ids = hass.components.group.expand_entity_ids([entity_id]) else: entity_ids = hass.states.entity_ids() for ent_id in entity_ids: domain = ha.split_entity_id(ent_id)[0] - module = get_component(domain) - try: - if module.is_on(hass, ent_id): - return True + component = getattr(hass.components, domain) - except AttributeError: - # module is None or method is_on does not exist - _LOGGER.exception("Failed to call %s.is_on for %s", - module, ent_id) + except ImportError: + _LOGGER.error('Failed to call %s.is_on: component not found', + domain) + continue + + if not hasattr(component, 'is_on'): + _LOGGER.warning("Component %s has no is_on method.", domain) + continue + + if component.is_on(ent_id): + return True return False @@ -161,10 +162,9 @@ def async_setup(hass, config): return if errors: - notif = get_component('persistent_notification') _LOGGER.error(errors) - notif.async_create( - hass, "Config error. See dev-info panel for details.", + hass.components.persistent_notification.async_create( + "Config error. See dev-info panel for details.", "Config validating", "{0}.check_config".format(ha.DOMAIN)) return diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 80c5e0ad1cc..39c86f3215f 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER, SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY) from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -44,6 +45,7 @@ ALARM_SERVICE_SCHEMA = vol.Schema({ }) +@bind_hass def alarm_disarm(hass, code=None, entity_id=None): """Send the alarm the command for disarm.""" data = {} @@ -55,6 +57,7 @@ def alarm_disarm(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) +@bind_hass def alarm_arm_home(hass, code=None, entity_id=None): """Send the alarm the command for arm home.""" data = {} @@ -66,6 +69,7 @@ def alarm_arm_home(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) +@bind_hass def alarm_arm_away(hass, code=None, entity_id=None): """Send the alarm the command for arm away.""" data = {} @@ -77,6 +81,7 @@ def alarm_arm_away(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) +@bind_hass def alarm_trigger(hass, code=None, entity_id=None): """Send the alarm the command for trigger.""" data = {} diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py new file mode 100644 index 00000000000..b554a667b2a --- /dev/null +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -0,0 +1,235 @@ +""" +Support for manual alarms controllable via MQTT. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.manual_mqtt/ +""" +import asyncio +import datetime +import logging + +import voluptuous as vol + +import homeassistant.components.alarm_control_panel as alarm +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, + CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, + CONF_DISARM_AFTER_TRIGGER) +import homeassistant.components.mqtt as mqtt + +from homeassistant.helpers.event import async_track_state_change +from homeassistant.core import callback + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_point_in_time + +CONF_PAYLOAD_DISARM = 'payload_disarm' +CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' +CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' + +DEFAULT_ALARM_NAME = 'HA Alarm' +DEFAULT_PENDING_TIME = 60 +DEFAULT_TRIGGER_TIME = 120 +DEFAULT_DISARM_AFTER_TRIGGER = False +DEFAULT_ARM_AWAY = 'ARM_AWAY' +DEFAULT_ARM_HOME = 'ARM_HOME' +DEFAULT_DISARM = 'DISARM' + +DEPENDENCIES = ['mqtt'] + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PLATFORM): 'manual_mqtt', + vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, + vol.Optional(CONF_CODE): cv.string, + vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): + vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): + vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_DISARM_AFTER_TRIGGER, + default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean, + vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, + vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, + vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the manual MQTT alarm platform.""" + add_devices([ManualMQTTAlarm( + hass, + config[CONF_NAME], + config.get(CONF_CODE), + config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME), + config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME), + config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER), + config.get(mqtt.CONF_STATE_TOPIC), + config.get(mqtt.CONF_COMMAND_TOPIC), + config.get(mqtt.CONF_QOS), + config.get(CONF_PAYLOAD_DISARM), + config.get(CONF_PAYLOAD_ARM_HOME), + config.get(CONF_PAYLOAD_ARM_AWAY))]) + + +class ManualMQTTAlarm(alarm.AlarmControlPanel): + """ + Representation of an alarm status. + + When armed, will be pending for 'pending_time', after that armed. + When triggered, will be pending for 'trigger_time'. After that will be + triggered for 'trigger_time', after that we return to the previous state + or disarm if `disarm_after_trigger` is true. + """ + + def __init__(self, hass, name, code, pending_time, + trigger_time, disarm_after_trigger, + state_topic, command_topic, qos, + payload_disarm, payload_arm_home, payload_arm_away): + """Init the manual MQTT alarm panel.""" + self._state = STATE_ALARM_DISARMED + self._hass = hass + self._name = name + self._code = str(code) if code else None + self._pending_time = datetime.timedelta(seconds=pending_time) + self._trigger_time = datetime.timedelta(seconds=trigger_time) + self._disarm_after_trigger = disarm_after_trigger + self._pre_trigger_state = self._state + self._state_ts = None + + self._state_topic = state_topic + self._command_topic = command_topic + self._qos = qos + self._payload_disarm = payload_disarm + self._payload_arm_home = payload_arm_home + self._payload_arm_away = payload_arm_away + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @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._state in (STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY) and \ + self._pending_time and self._state_ts + self._pending_time > \ + dt_util.utcnow(): + return STATE_ALARM_PENDING + + if self._state == STATE_ALARM_TRIGGERED and self._trigger_time: + if self._state_ts + self._pending_time > dt_util.utcnow(): + return STATE_ALARM_PENDING + elif (self._state_ts + self._pending_time + + self._trigger_time) < dt_util.utcnow(): + if self._disarm_after_trigger: + return STATE_ALARM_DISARMED + return self._pre_trigger_state + + return self._state + + @property + def code_format(self): + """One or more characters.""" + return None if self._code is None else '.+' + + def alarm_disarm(self, code=None): + """Send disarm command.""" + if not self._validate_code(code, STATE_ALARM_DISARMED): + return + + self._state = STATE_ALARM_DISARMED + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_HOME): + return + + self._state = STATE_ALARM_ARMED_HOME + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + if self._pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + self._pending_time) + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): + return + + self._state = STATE_ALARM_ARMED_AWAY + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + if self._pending_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + self._pending_time) + + def alarm_trigger(self, code=None): + """Send alarm trigger command. No code needed.""" + self._pre_trigger_state = self._state + self._state = STATE_ALARM_TRIGGERED + self._state_ts = dt_util.utcnow() + self.schedule_update_ha_state() + + if self._trigger_time: + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + self._pending_time) + + track_point_in_time( + self._hass, self.async_update_ha_state, + self._state_ts + self._pending_time + self._trigger_time) + + def _validate_code(self, code, state): + """Validate given code.""" + check = self._code is None or code == self._code + if not check: + _LOGGER.warning("Invalid code given for %s", state) + return check + + def async_added_to_hass(self): + """Subscribe mqtt events. + + This method must be run in the event loop and returns a coroutine. + """ + async_track_state_change( + self.hass, self.entity_id, self._async_state_changed_listener + ) + + @callback + def message_received(topic, payload, qos): + """Run when new MQTT message has been received.""" + if payload == self._payload_disarm: + self.async_alarm_disarm(self._code) + elif payload == self._payload_arm_home: + self.async_alarm_arm_home(self._code) + elif payload == self._payload_arm_away: + self.async_alarm_arm_away(self._code) + else: + _LOGGER.warning("Received unexpected payload: %s", payload) + return + + return mqtt.async_subscribe( + self.hass, self._command_topic, message_received, self._qos) + + @asyncio.coroutine + def _async_state_changed_listener(self, entity_id, old_state, new_state): + """Publish state change to MQTT.""" + mqtt.async_publish(self.hass, self._state_topic, new_state.state, + self._qos, True) diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index fadfbc41a6f..a600a01fcfd 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -15,9 +15,8 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv -import homeassistant.loader as loader -REQUIREMENTS = ['simplisafe-python==1.0.2'] +REQUIREMENTS = ['simplisafe-python==1.0.3'] _LOGGER = logging.getLogger(__name__) @@ -42,7 +41,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - persistent_notification = loader.get_component('persistent_notification') simplisafe = SimpliSafeApiInterface() status = simplisafe.set_credentials(username, password) if status: @@ -53,8 +51,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: message = 'Failed to log into SimpliSafe. Check credentials.' _LOGGER.error(message) - persistent_notification.create( - hass, message, + hass.components.persistent_notification.create( + message, title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) return False diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index d4948429b81..011cc3ad21d 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['alarmdecoder==0.12.1.0'] +REQUIREMENTS = ['alarmdecoder==0.12.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py index 1a3708fb746..c121268f93d 100644 --- a/homeassistant/components/alexa.py +++ b/homeassistant/components/alexa.py @@ -15,8 +15,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.const import HTTP_BAD_REQUEST -from homeassistant.helpers import template, script, config_validation as cv -from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers import intent, template, config_validation as cv +from homeassistant.components import http _LOGGER = logging.getLogger(__name__) @@ -60,6 +60,12 @@ class SpeechType(enum.Enum): ssml = "SSML" +SPEECH_MAPPINGS = { + 'plain': SpeechType.plaintext, + 'ssml': SpeechType.ssml, +} + + class CardType(enum.Enum): """The Alexa card types.""" @@ -69,20 +75,6 @@ class CardType(enum.Enum): CONFIG_SCHEMA = vol.Schema({ DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CARD): { - vol.Required(CONF_TYPE): cv.enum(CardType), - vol.Required(CONF_TITLE): cv.template, - vol.Required(CONF_CONTENT): cv.template, - }, - vol.Optional(CONF_SPEECH): { - vol.Required(CONF_TYPE): cv.enum(SpeechType), - vol.Required(CONF_TEXT): cv.template, - } - } - }, CONF_FLASH_BRIEFINGS: { cv.string: vol.All(cv.ensure_list, [{ vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string, @@ -96,40 +88,27 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Activate Alexa component.""" - intents = config[DOMAIN].get(CONF_INTENTS, {}) flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {}) - hass.http.register_view(AlexaIntentsView(hass, intents)) + hass.http.register_view(AlexaIntentsView) hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings)) return True -class AlexaIntentsView(HomeAssistantView): +class AlexaIntentsView(http.HomeAssistantView): """Handle Alexa requests.""" url = INTENTS_API_ENDPOINT name = 'api:alexa' - def __init__(self, hass, intents): - """Initialize Alexa view.""" - super().__init__() - - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Alexa intent {}".format(name)) - - self.intents = intents - @asyncio.coroutine def post(self, request): """Handle Alexa.""" + hass = request.app['hass'] data = yield from request.json() _LOGGER.debug('Received Alexa request: %s', data) @@ -146,14 +125,14 @@ class AlexaIntentsView(HomeAssistantView): if req_type == 'SessionEndedRequest': return None - intent = req.get('intent') - response = AlexaResponse(request.app['hass'], intent) + alexa_intent_info = req.get('intent') + alexa_response = AlexaResponse(hass, alexa_intent_info) if req_type == 'LaunchRequest': - response.add_speech( + alexa_response.add_speech( SpeechType.plaintext, "Hello, and welcome to the future. How may I help?") - return self.json(response) + return self.json(alexa_response) if req_type != 'IntentRequest': _LOGGER.warning('Received unsupported request: %s', req_type) @@ -161,38 +140,47 @@ class AlexaIntentsView(HomeAssistantView): 'Received unsupported request: {}'.format(req_type), HTTP_BAD_REQUEST) - intent_name = intent['name'] - config = self.intents.get(intent_name) + intent_name = alexa_intent_info['name'] - if config is None: + try: + intent_response = yield from intent.async_handle( + hass, DOMAIN, intent_name, + {key: {'value': value} for key, value + in alexa_response.variables.items()}) + except intent.UnknownIntent as err: _LOGGER.warning('Received unknown intent %s', intent_name) - response.add_speech( + alexa_response.add_speech( SpeechType.plaintext, "This intent is not yet configured within Home Assistant.") - return self.json(response) + return self.json(alexa_response) - speech = config.get(CONF_SPEECH) - card = config.get(CONF_CARD) - action = config.get(CONF_ACTION) + except intent.InvalidSlotInfo as err: + _LOGGER.error('Received invalid slot data from Alexa: %s', err) + return self.json_message('Invalid slot data received', + HTTP_BAD_REQUEST) + except intent.IntentError: + _LOGGER.exception('Error handling request for %s', intent_name) + return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - if action is not None: - yield from action.async_run(response.variables) + for intent_speech, alexa_speech in SPEECH_MAPPINGS.items(): + if intent_speech in intent_response.speech: + alexa_response.add_speech( + alexa_speech, + intent_response.speech[intent_speech]['speech']) + break - # pylint: disable=unsubscriptable-object - if speech is not None: - response.add_speech(speech[CONF_TYPE], speech[CONF_TEXT]) + if 'simple' in intent_response.card: + alexa_response.add_card( + 'simple', intent_response.card['simple']['title'], + intent_response.card['simple']['content']) - if card is not None: - response.add_card(card[CONF_TYPE], card[CONF_TITLE], - card[CONF_CONTENT]) - - return self.json(response) + return self.json(alexa_response) class AlexaResponse(object): """Help generating the response for Alexa.""" - def __init__(self, hass, intent=None): + def __init__(self, hass, intent_info): """Initialize the response.""" self.hass = hass self.speech = None @@ -201,8 +189,9 @@ class AlexaResponse(object): self.session_attributes = {} self.should_end_session = True self.variables = {} - if intent is not None and 'slots' in intent: - for key, value in intent['slots'].items(): + # Intent is None if request was a LaunchRequest or SessionEndedRequest + if intent_info is not None: + for key, value in intent_info.get('slots', {}).items(): if 'value' in value: underscored_key = key.replace('.', '_') self.variables[underscored_key] = value['value'] @@ -272,7 +261,7 @@ class AlexaResponse(object): } -class AlexaFlashBriefingView(HomeAssistantView): +class AlexaFlashBriefingView(http.HomeAssistantView): """Handle Alexa Flash Briefing skill requests.""" url = FLASH_BRIEFINGS_API_ENDPOINT diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index 76ba13c3409..157b9574a06 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -11,7 +11,6 @@ import aiohttp import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout -import homeassistant.loader as loader from homeassistant.const import ( CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, CONF_SCAN_INTERVAL, HTTP_BASIC_AUTHENTICATION) @@ -92,7 +91,6 @@ def setup(hass, config): amcrest_cams = config[DOMAIN] - persistent_notification = loader.get_component('persistent_notification') for device in amcrest_cams: camera = AmcrestCamera(device.get(CONF_HOST), device.get(CONF_PORT), @@ -103,8 +101,8 @@ def setup(hass, config): except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Amcrest camera: %s", str(ex)) - persistent_notification.create( - hass, 'Error: {}
' + hass.components.persistent_notification.create( + 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(ex), title=NOTIFICATION_TITLE, diff --git a/homeassistant/components/apiai.py b/homeassistant/components/apiai.py index 989c1a596f3..eb6cd0027f7 100644 --- a/homeassistant/components/apiai.py +++ b/homeassistant/components/apiai.py @@ -5,13 +5,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/apiai/ """ import asyncio -import copy import logging import voluptuous as vol from homeassistant.const import PROJECT_NAME, HTTP_BAD_REQUEST -from homeassistant.helpers import template, script, config_validation as cv +from homeassistant.helpers import intent, template from homeassistant.components.http import HomeAssistantView _LOGGER = logging.getLogger(__name__) @@ -29,24 +28,14 @@ DOMAIN = 'apiai' DEPENDENCIES = ['http'] CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_SPEECH): cv.template, - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ASYNC_ACTION, - default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean - } - } - } + DOMAIN: {} }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +@asyncio.coroutine +def async_setup(hass, config): """Activate API.AI component.""" - intents = config[DOMAIN].get(CONF_INTENTS, {}) - - hass.http.register_view(ApiaiIntentsView(hass, intents)) + hass.http.register_view(ApiaiIntentsView) return True @@ -57,24 +46,10 @@ class ApiaiIntentsView(HomeAssistantView): url = INTENTS_API_ENDPOINT name = 'api:apiai' - def __init__(self, hass, intents): - """Initialize API.AI view.""" - super().__init__() - - self.hass = hass - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Apiai intent {}".format(name)) - - self.intents = intents - @asyncio.coroutine def post(self, request): """Handle API.AI.""" + hass = request.app['hass'] data = yield from request.json() _LOGGER.debug("Received api.ai request: %s", data) @@ -91,55 +66,41 @@ class ApiaiIntentsView(HomeAssistantView): if action_incomplete: return None - # use intent to no mix HASS actions with this parameter - intent = req.get('action') + action = req.get('action') parameters = req.get('parameters') - # contexts = req.get('contexts') - response = ApiaiResponse(parameters) + apiai_response = ApiaiResponse(parameters) - # Default Welcome Intent - # Maybe is better to handle this in api.ai directly? - # - # if intent == 'input.welcome': - # response.add_speech( - # "Hello, and welcome to the future. How may I help?") - # return self.json(response) - - if intent == "": + if action == "": _LOGGER.warning("Received intent with empty action") - response.add_speech( + apiai_response.add_speech( "You have not defined an action in your api.ai intent.") - return self.json(response) + return self.json(apiai_response) - config = self.intents.get(intent) + try: + intent_response = yield from intent.async_handle( + hass, DOMAIN, action, + {key: {'value': value} for key, value + in parameters.items()}) - if config is None: - _LOGGER.warning("Received unknown intent %s", intent) - response.add_speech( - "Intent '%s' is not yet configured within Home Assistant." % - intent) - return self.json(response) + except intent.UnknownIntent as err: + _LOGGER.warning('Received unknown intent %s', action) + apiai_response.add_speech( + "This intent is not yet configured within Home Assistant.") + return self.json(apiai_response) - speech = config.get(CONF_SPEECH) - action = config.get(CONF_ACTION) - async_action = config.get(CONF_ASYNC_ACTION) + except intent.InvalidSlotInfo as err: + _LOGGER.error('Received invalid slot data: %s', err) + return self.json_message('Invalid slot data received', + HTTP_BAD_REQUEST) + except intent.IntentError: + _LOGGER.exception('Error handling request for %s', action) + return self.json_message('Error handling intent', HTTP_BAD_REQUEST) - if action is not None: - # API.AI expects a response in less than 5s - if async_action: - # Do not wait for the action to be executed. - # Needed if the action will take longer than 5s to execute - self.hass.async_add_job(action.async_run(response.parameters)) - else: - # Wait for the action to be executed so we can use results to - # render the answer - yield from action.async_run(response.parameters) + if 'plain' in intent_response.speech: + apiai_response.add_speech( + intent_response.speech['plain']['speech']) - # pylint: disable=unsubscriptable-object - if speech is not None: - response.add_speech(speech) - - return self.json(response) + return self.json(apiai_response) class ApiaiResponse(object): diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index f5850bb21a9..c5f40ca5db8 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -15,7 +15,6 @@ from homeassistant.config import load_yaml_config_file from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_APPLE_TV -from homeassistant.loader import get_component import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyatv==0.3.4'] @@ -66,27 +65,24 @@ APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({ def request_configuration(hass, config, atv, credentials): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator @asyncio.coroutine def configuration_callback(callback_data): """Handle the submitted configuration.""" from pyatv import exceptions pin = callback_data.get('pin') - notification = get_component('persistent_notification') try: yield from atv.airplay.finish_authentication(pin) - notification.async_create( - hass, + hass.components.persistent_notification.async_create( 'Authentication succeeded!

Add the following ' 'to credentials: in your apple_tv configuration:

' '{0}'.format(credentials), title=NOTIFICATION_AUTH_TITLE, notification_id=NOTIFICATION_AUTH_ID) except exceptions.DeviceAuthenticationError as ex: - notification.async_create( - hass, + hass.components.persistent_notification.async_create( 'Authentication failed! Did you enter correct PIN?

' 'Details: {0}'.format(ex), title=NOTIFICATION_AUTH_TITLE, @@ -119,9 +115,7 @@ def scan_for_apple_tvs(hass): if not devices: devices = ['No device(s) found'] - notification = get_component('persistent_notification') - notification.async_create( - hass, + hass.components.persistent_notification.async_create( 'The following devices were found:

' + '

'.join(devices), title=NOTIFICATION_SCAN_TITLE, diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 630420bd3e5..1ba2acb4fe0 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -9,7 +9,6 @@ import logging import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout -import homeassistant.loader as loader from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD @@ -40,7 +39,6 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) - persistent_notification = loader.get_component('persistent_notification') try: from pyarlo import PyArlo @@ -50,8 +48,8 @@ def setup(hass, config): hass.data[DATA_ARLO] = arlo except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgar Arlo: %s", str(ex)) - persistent_notification.create( - hass, 'Error: {}
' + hass.components.persistent_notification.create( + 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(ex), title=NOTIFICATION_TITLE, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 09f0e286755..27332bfaa9f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import CoreState +from homeassistant.loader import bind_hass from homeassistant import config as conf_util from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, @@ -105,6 +106,7 @@ TRIGGER_SERVICE_SCHEMA = vol.Schema({ RELOAD_SERVICE_SCHEMA = vol.Schema({}) +@bind_hass def is_on(hass, entity_id): """ Return true if specified automation entity_id is on. @@ -114,35 +116,41 @@ def is_on(hass, entity_id): return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def turn_on(hass, entity_id=None): """Turn on specified automation or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TURN_ON, data) +@bind_hass def turn_off(hass, entity_id=None): """Turn off specified automation or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) +@bind_hass def toggle(hass, entity_id=None): """Toggle specified automation or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TOGGLE, data) +@bind_hass def trigger(hass, entity_id=None): """Trigger specified automation or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TRIGGER, data) +@bind_hass def reload(hass): """Reload the automation from config.""" hass.services.call(DOMAIN, SERVICE_RELOAD) +@bind_hass def async_reload(hass): """Reload the automation from config. diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index fbd1570a1e0..8ad5c40bb80 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -12,13 +12,11 @@ import homeassistant.util.dt as dt_util from homeassistant.const import MATCH_ALL, CONF_PLATFORM from homeassistant.helpers.event import ( async_track_state_change, async_track_point_in_utc_time) -from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv CONF_ENTITY_ID = 'entity_id' CONF_FROM = 'from' CONF_TO = 'to' -CONF_STATE = 'state' CONF_FOR = 'for' TRIGGER_SCHEMA = vol.All( @@ -28,11 +26,9 @@ TRIGGER_SCHEMA = vol.All( # These are str on purpose. Want to catch YAML conversions CONF_FROM: str, CONF_TO: str, - CONF_STATE: str, CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta), }), - vol.Any(cv.key_dependency(CONF_FOR, CONF_TO), - cv.key_dependency(CONF_FOR, CONF_STATE)) + cv.key_dependency(CONF_FOR, CONF_TO), ) @@ -41,7 +37,7 @@ def async_trigger(hass, config, action): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) - to_state = get_deprecated(config, CONF_TO, CONF_STATE, MATCH_ALL) + to_state = config.get(CONF_TO, MATCH_ALL) time_delta = config.get(CONF_FOR) async_remove_state_for_cancel = None async_remove_state_for_listener = None diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index 8ba082e3331..a3a8496c3c5 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -10,7 +10,7 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import CONF_AT, CONF_PLATFORM, CONF_AFTER +from homeassistant.const import CONF_AT, CONF_PLATFORM from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_time_change @@ -23,12 +23,10 @@ _LOGGER = logging.getLogger(__name__) TRIGGER_SCHEMA = vol.All(vol.Schema({ vol.Required(CONF_PLATFORM): 'time', CONF_AT: cv.time, - CONF_AFTER: cv.time, CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)), CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)), CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)), -}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, - CONF_SECONDS, CONF_AT, CONF_AFTER)) +}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT)) @asyncio.coroutine @@ -37,11 +35,6 @@ def async_trigger(hass, config, action): if CONF_AT in config: at_time = config.get(CONF_AT) hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second - elif CONF_AFTER in config: - _LOGGER.warning("'after' is deprecated for the time trigger. Please " - "rename 'after' to 'at' in your configuration file.") - at_time = config.get(CONF_AFTER) - hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second else: hours = config.get(CONF_HOURS) minutes = config.get(CONF_MINUTES) diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index bb1ec05496a..d83e07989e6 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -21,7 +21,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity -from homeassistant.loader import get_component REQUIREMENTS = ['axis==8'] @@ -79,7 +78,7 @@ SERVICE_SCHEMA = vol.Schema({ def request_configuration(hass, name, host, serialnumber): """Request configuration steps from the user.""" - configurator = get_component('configurator') + configurator = hass.components.configurator def configuration_callback(callback_data): """Called when config is submitted.""" @@ -242,12 +241,11 @@ def setup_device(hass, config): if enable_metadatastream: device.initialize_new_event = event_initialized if not device.initiate_metadatastream(): - notification = get_component('persistent_notification') - notification.create(hass, - 'Dependency missing for sensors, ' - 'please check documentation', - title=DOMAIN, - notification_id='axis_notification') + hass.components.persistent_notification.create( + 'Dependency missing for sensors, ' + 'please check documentation', + title=DOMAIN, + notification_id='axis_notification') AXIS_DEVICES[device.serial_number] = device diff --git a/homeassistant/components/binary_sensor/ping.py b/homeassistant/components/binary_sensor/ping.py index cb5bc4333a2..1919c7ab64d 100644 --- a/homeassistant/components/binary_sensor/ping.py +++ b/homeassistant/components/binary_sensor/ping.py @@ -35,6 +35,9 @@ SCAN_INTERVAL = timedelta(minutes=5) PING_MATCHER = re.compile( r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') +PING_MATCHER_BUSYBOX = re.compile( + r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)') + WIN32_PING_MATCHER = re.compile( r'(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms') @@ -126,7 +129,14 @@ class PingData(object): 'avg': rtt_avg, 'max': rtt_max, 'mdev': ''} - + if 'max/' not in str(out): + match = PING_MATCHER_BUSYBOX.search(str(out).split('\n')[-1]) + rtt_min, rtt_avg, rtt_max = match.groups() + return { + 'min': rtt_min, + 'avg': rtt_avg, + 'max': rtt_max, + 'mdev': ''} match = PING_MATCHER.search(str(out).split('\n')[-1]) rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() return { diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py index abdbc8251c7..6d1745700bd 100644 --- a/homeassistant/components/binary_sensor/rest.py +++ b/homeassistant/components/binary_sensor/rest.py @@ -107,6 +107,8 @@ class RestBinarySensor(BinarySensorDevice): if self.rest.data is None: return False + response = self.rest.data + if self._value_template is not None: response = self._value_template.\ async_render_with_possible_json_value(self.rest.data, False) diff --git a/homeassistant/components/binary_sensor/velbus.py b/homeassistant/components/binary_sensor/velbus.py new file mode 100644 index 00000000000..214edcf9463 --- /dev/null +++ b/homeassistant/components/binary_sensor/velbus.py @@ -0,0 +1,96 @@ +""" +Support for Velbus Binary Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.velbus/ +""" +import asyncio +import logging + + +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_DEVICES +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import PLATFORM_SCHEMA +from homeassistant.components.velbus import DOMAIN +import homeassistant.helpers.config_validation as cv + + +DEPENDENCIES = ['velbus'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ + { + vol.Required('module'): cv.positive_int, + vol.Required('channel'): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + vol.Optional('is_pushbutton'): cv.boolean + } + ]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Velbus binary sensors.""" + velbus = hass.data[DOMAIN] + + add_devices(VelbusBinarySensor(sensor, velbus) + for sensor in config[CONF_DEVICES]) + + +class VelbusBinarySensor(BinarySensorDevice): + """Representation of a Velbus Binary Sensor.""" + + def __init__(self, binary_sensor, velbus): + """Initialize a Velbus light.""" + self._velbus = velbus + self._name = binary_sensor[CONF_NAME] + self._module = binary_sensor['module'] + self._channel = binary_sensor['channel'] + self._is_pushbutton = 'is_pushbutton' in binary_sensor \ + and binary_sensor['is_pushbutton'] + self._state = False + + @asyncio.coroutine + def async_added_to_hass(self): + """Add listener for Velbus messages on bus.""" + yield from self.hass.async_add_job( + self._velbus.subscribe, self._on_message) + + def _on_message(self, message): + import velbus + if isinstance(message, velbus.PushButtonStatusMessage): + if message.address == self._module and \ + self._channel in message.get_channels(): + if self._is_pushbutton: + if self._channel in message.closed: + self._toggle() + else: + pass + else: + self._toggle() + + def _toggle(self): + if self._state is True: + self._state = False + else: + self._state = True + self.schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return the display name of this sensor.""" + return self._name + + @property + def is_on(self): + """Return true if the sensor is on.""" + return self._state diff --git a/homeassistant/components/binary_sensor/xiaomi.py b/homeassistant/components/binary_sensor/xiaomi.py new file mode 100644 index 00000000000..fafdc098c5d --- /dev/null +++ b/homeassistant/components/binary_sensor/xiaomi.py @@ -0,0 +1,316 @@ +"""Support for Xiaomi binary sensors.""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +NO_CLOSE = 'no_close' +ATTR_OPEN_SINCE = 'Open since' + +MOTION = 'motion' +NO_MOTION = 'no_motion' +ATTR_NO_MOTION_SINCE = 'No motion since' + +DENSITY = 'density' +ATTR_DENSITY = 'Density' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['binary_sensor']: + model = device['model'] + if model == 'motion': + devices.append(XiaomiMotionSensor(device, hass, gateway)) + elif model == 'sensor_motion.aq2': + devices.append(XiaomiMotionSensor(device, hass, gateway)) + elif model == 'magnet': + devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'sensor_magnet.aq2': + devices.append(XiaomiDoorSensor(device, gateway)) + elif model == 'smoke': + devices.append(XiaomiSmokeSensor(device, gateway)) + elif model == 'natgas': + devices.append(XiaomiNatgasSensor(device, gateway)) + elif model == 'switch': + devices.append(XiaomiButton(device, 'Switch', 'status', + hass, gateway)) + elif model == 'sensor_switch.aq2': + devices.append(XiaomiButton(device, 'Switch', 'status', + hass, gateway)) + elif model == '86sw1': + devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0', + hass, gateway)) + elif model == '86sw2': + devices.append(XiaomiButton(device, 'Wall Switch (Left)', + 'channel_0', hass, gateway)) + devices.append(XiaomiButton(device, 'Wall Switch (Right)', + 'channel_1', hass, gateway)) + devices.append(XiaomiButton(device, 'Wall Switch (Both)', + 'dual_channel', hass, gateway)) + elif model == 'cube': + devices.append(XiaomiCube(device, hass, gateway)) + add_devices(devices) + + +class XiaomiBinarySensor(XiaomiDevice, BinarySensorDevice): + """Representation of a base XiaomiBinarySensor.""" + + def __init__(self, device, name, xiaomi_hub, data_key, device_class): + """Initialize the XiaomiSmokeSensor.""" + self._data_key = data_key + self._device_class = device_class + self._should_poll = False + self._density = 0 + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def should_poll(self): + """Return True if entity has to be polled for state.""" + return self._should_poll + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + @property + def device_class(self): + """Return the class of binary sensor.""" + return self._device_class + + def update(self): + """Update the sensor state.""" + _LOGGER.debug('Updating xiaomi sensor by polling') + self._get_from_hub(self._sid) + + +class XiaomiNatgasSensor(XiaomiBinarySensor): + """Representation of a XiaomiNatgasSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiSmokeSensor.""" + self._density = None + XiaomiBinarySensor.__init__(self, device, 'Natgas Sensor', xiaomi_hub, + 'alarm', 'gas') + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_DENSITY: self._density} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + if DENSITY in data: + self._density = int(data.get(DENSITY)) + + value = data.get(self._data_key) + if value is None: + return False + + if value == '1': + if self._state: + return False + self._state = True + return True + elif value == '0': + if self._state: + self._state = False + return True + return False + + +class XiaomiMotionSensor(XiaomiBinarySensor): + """Representation of a XiaomiMotionSensor.""" + + def __init__(self, device, hass, xiaomi_hub): + """Initialize the XiaomiMotionSensor.""" + self._hass = hass + self._no_motion_since = 0 + XiaomiBinarySensor.__init__(self, device, 'Motion Sensor', xiaomi_hub, + 'status', 'motion') + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_NO_MOTION_SINCE: self._no_motion_since} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + if NO_MOTION in data: # handle push from the hub + self._no_motion_since = data[NO_MOTION] + self._state = False + return True + + value = data.get(self._data_key) + if value is None: + return False + + if value == MOTION: + self._should_poll = True + if self.entity_id is not None: + self._hass.bus.fire('motion', { + 'entity_id': self.entity_id + }) + + self._no_motion_since = 0 + if self._state: + return False + self._state = True + return True + elif value == NO_MOTION: + if not self._state: + return False + self._state = False + return True + + +class XiaomiDoorSensor(XiaomiBinarySensor): + """Representation of a XiaomiDoorSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiDoorSensor.""" + self._open_since = 0 + XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor', + xiaomi_hub, 'status', 'opening') + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_OPEN_SINCE: self._open_since} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + self._should_poll = False + if NO_CLOSE in data: # handle push from the hub + self._open_since = data[NO_CLOSE] + return True + + value = data.get(self._data_key) + if value is None: + return False + + if value == 'open': + self._should_poll = True + if self._state: + return False + self._state = True + return True + elif value == 'close': + self._open_since = 0 + if self._state: + self._state = False + return True + return False + + +class XiaomiSmokeSensor(XiaomiBinarySensor): + """Representation of a XiaomiSmokeSensor.""" + + def __init__(self, device, xiaomi_hub): + """Initialize the XiaomiSmokeSensor.""" + self._density = 0 + XiaomiBinarySensor.__init__(self, device, 'Smoke Sensor', xiaomi_hub, + 'alarm', 'smoke') + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {ATTR_DENSITY: self._density} + attrs.update(super().device_state_attributes) + return attrs + + def parse_data(self, data): + """Parse data sent by gateway.""" + if DENSITY in data: + self._density = int(data.get(DENSITY)) + value = data.get(self._data_key) + if value is None: + return False + + if value == '1': + if self._state: + return False + self._state = True + return True + elif value == '0': + if self._state: + self._state = False + return True + return False + + +class XiaomiButton(XiaomiBinarySensor): + """Representation of a Xiaomi Button.""" + + def __init__(self, device, name, data_key, hass, xiaomi_hub): + """Initialize the XiaomiButton.""" + self._hass = hass + XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub, + data_key, None) + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + + if value == 'long_click_press': + self._state = True + click_type = 'long_click_press' + elif value == 'long_click_release': + self._state = False + click_type = 'hold' + elif value == 'click': + click_type = 'single' + elif value == 'double_click': + click_type = 'double' + elif value == 'both_click': + click_type = 'both' + else: + return False + + self._hass.bus.fire('click', { + 'entity_id': self.entity_id, + 'click_type': click_type + }) + if value in ['long_click_press', 'long_click_release']: + return True + return False + + +class XiaomiCube(XiaomiBinarySensor): + """Representation of a Xiaomi Cube.""" + + def __init__(self, device, hass, xiaomi_hub): + """Initialize the Xiaomi Cube.""" + self._hass = hass + self._state = False + XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub, + None, None) + + def parse_data(self, data): + """Parse data sent by gateway.""" + if 'status' in data: + self._hass.bus.fire('cube_action', { + 'entity_id': self.entity_id, + 'action_type': data['status'] + }) + + if 'rotate' in data: + self._hass.bus.fire('cube_action', { + 'entity_id': self.entity_id, + 'action_type': 'rotate', + 'action_value': float(data['rotate'].replace(",", ".")) + }) + return False diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 5b97e102d8d..a7d778d99aa 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -23,6 +23,7 @@ from homeassistant.core import callback from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -55,6 +56,7 @@ CAMERA_SERVICE_SCHEMA = vol.Schema({ }) +@bind_hass def enable_motion_detection(hass, entity_id=None): """Enable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None @@ -62,6 +64,7 @@ def enable_motion_detection(hass, entity_id=None): DOMAIN, SERVICE_EN_MOTION, data)) +@bind_hass def disable_motion_detection(hass, entity_id=None): """Disable Motion Detection.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index f1c94f79c0b..711eb75a744 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/camera.onvif/ """ import asyncio import logging +import os import voluptuous as vol @@ -54,6 +55,7 @@ class ONVIFCamera(Camera): def __init__(self, hass, config): """Initialize a ONVIF camera.""" from onvif import ONVIFService + import onvif super().__init__() self._name = config.get(CONF_NAME) @@ -63,7 +65,7 @@ class ONVIFCamera(Camera): config.get(CONF_HOST), config.get(CONF_PORT)), config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - '{}/deps/onvif/wsdl/media.wsdl'.format(hass.config.config_dir) + '{}/wsdl/media.wsdl'.format(os.path.dirname(onvif.__file__)) ) self._input = media.GetStreamUri().Uri _LOGGER.debug("ONVIF Camera Using the following URL for %s: %s", diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 5b6c025b3e3..6dd66817d43 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -14,6 +14,7 @@ from numbers import Number import voluptuous as vol from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.util.temperature import convert as convert_temperature from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity @@ -114,6 +115,7 @@ SET_SWING_MODE_SCHEMA = vol.Schema({ }) +@bind_hass def set_away_mode(hass, away_mode, entity_id=None): """Turn all or specified climate devices away mode on.""" data = { @@ -126,6 +128,7 @@ def set_away_mode(hass, away_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data) +@bind_hass def set_hold_mode(hass, hold_mode, entity_id=None): """Set new hold mode.""" data = { @@ -138,6 +141,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_HOLD_MODE, data) +@bind_hass def set_aux_heat(hass, aux_heat, entity_id=None): """Turn all or specified climate devices auxillary heater on.""" data = { @@ -150,6 +154,7 @@ def set_aux_heat(hass, aux_heat, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data) +@bind_hass def set_temperature(hass, temperature=None, entity_id=None, target_temp_high=None, target_temp_low=None, operation_mode=None): @@ -167,6 +172,7 @@ def set_temperature(hass, temperature=None, entity_id=None, hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs) +@bind_hass def set_humidity(hass, humidity, entity_id=None): """Set new target humidity.""" data = {ATTR_HUMIDITY: humidity} @@ -177,6 +183,7 @@ def set_humidity(hass, humidity, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data) +@bind_hass def set_fan_mode(hass, fan, entity_id=None): """Set all or specified climate devices fan mode on.""" data = {ATTR_FAN_MODE: fan} @@ -187,6 +194,7 @@ def set_fan_mode(hass, fan, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data) +@bind_hass def set_operation_mode(hass, operation_mode, entity_id=None): """Set new target operation mode.""" data = {ATTR_OPERATION_MODE: operation_mode} @@ -197,6 +205,7 @@ def set_operation_mode(hass, operation_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data) +@bind_hass def set_swing_mode(hass, swing_mode, entity_id=None): """Set new target swing mode.""" data = {ATTR_SWING_MODE: swing_mode} diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py index 9d6c1bbab5b..271616daf8b 100644 --- a/homeassistant/components/climate/maxcube.py +++ b/homeassistant/components/climate/maxcube.py @@ -10,7 +10,6 @@ import logging from homeassistant.components.climate import ClimateDevice, STATE_AUTO from homeassistant.components.maxcube import MAXCUBE_HANDLE from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE -from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) @@ -140,7 +139,7 @@ class MaxCubeClimate(ClimateDevice): def map_temperature_max_hass(temperature): """Map Temperature from MAX! to HASS.""" if temperature is None: - return STATE_UNKNOWN + return 0.0 return temperature diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index e502e0a0253..660a62a5b89 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -12,6 +12,7 @@ import logging from homeassistant.core import callback as async_callback from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \ ATTR_ENTITY_PICTURE +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import generate_entity_id from homeassistant.util.async import run_callback_threadsafe @@ -37,6 +38,7 @@ STATE_CONFIGURE = 'configure' STATE_CONFIGURED = 'configured' +@bind_hass def request_config( hass, name, callback, description=None, description_image=None, submit_caption=None, fields=None, link_name=None, link_url=None, diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 591190383a0..62611b82496 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -4,6 +4,7 @@ Support for functionality to have conversations with Home Assistant. For more details about this component, please refer to the documentation at https://home-assistant.io/components/conversation/ """ +import asyncio import logging import re import warnings @@ -11,16 +12,17 @@ import warnings import voluptuous as vol from homeassistant import core +from homeassistant.loader import bind_hass from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import script + ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, HTTP_BAD_REQUEST) +from homeassistant.helpers import intent, config_validation as cv +from homeassistant.components import http -REQUIREMENTS = ['fuzzywuzzy==0.15.0'] +REQUIREMENTS = ['fuzzywuzzy==0.15.1'] +DEPENDENCIES = ['http'] ATTR_TEXT = 'text' -ATTR_SENTENCE = 'sentence' DOMAIN = 'conversation' REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') @@ -28,79 +30,174 @@ REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)') SERVICE_PROCESS = 'process' SERVICE_PROCESS_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEXT): vol.All(cv.string, vol.Lower), + vol.Required(ATTR_TEXT): cv.string, }) CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({ - cv.string: vol.Schema({ - vol.Required(ATTR_SENTENCE): cv.string, - vol.Required('action'): cv.SCRIPT_SCHEMA, + vol.Optional('intents'): vol.Schema({ + cv.string: vol.All(cv.ensure_list, [cv.string]) }) })}, extra=vol.ALLOW_EXTRA) +_LOGGER = logging.getLogger(__name__) -def setup(hass, config): + +@core.callback +@bind_hass +def async_register(hass, intent_type, utterances): + """Register an intent. + + Registrations don't require conversations to be loaded. They will become + active once the conversation component is loaded. + """ + intents = hass.data.get(DOMAIN) + + if intents is None: + intents = hass.data[DOMAIN] = {} + + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(_create_matcher(utterance) for utterance in utterances) + + +@asyncio.coroutine +def async_setup(hass, config): """Register the process service.""" warnings.filterwarnings('ignore', module='fuzzywuzzy') - from fuzzywuzzy import process as fuzzyExtract - logger = logging.getLogger(__name__) config = config.get(DOMAIN, {}) + intents = hass.data.get(DOMAIN) - choices = {attrs[ATTR_SENTENCE]: script.Script( - hass, - attrs['action'], - name) - for name, attrs in config.items()} + if intents is None: + intents = hass.data[DOMAIN] = {} + for intent_type, utterances in config.get('intents', {}).items(): + conf = intents.get(intent_type) + + if conf is None: + conf = intents[intent_type] = [] + + conf.extend(_create_matcher(utterance) for utterance in utterances) + + @asyncio.coroutine def process(service): """Parse text into commands.""" - # if actually configured - if choices: - text = service.data[ATTR_TEXT] - match = fuzzyExtract.extractOne(text, choices.keys()) - scorelimit = 60 # arbitrary value - logging.info( - 'matched up text %s and found %s', - text, - [match[0] if match[1] > scorelimit else 'nothing'] - ) - if match[1] > scorelimit: - choices[match[0]].run() # run respective script - return - text = service.data[ATTR_TEXT] - match = REGEX_TURN_COMMAND.match(text) + yield from _process(hass, text) - if not match: - logger.error("Unable to process: %s", text) - return - - name, command = match.groups() - entities = {state.entity_id: state.name for state in hass.states.all()} - entity_ids = fuzzyExtract.extractOne( - name, entities, score_cutoff=65)[2] - - if not entity_ids: - logger.error( - "Could not find entity id %s from text %s", name, text) - return - - if command == 'on': - hass.services.call(core.DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - elif command == 'off': - hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: entity_ids, - }, blocking=True) - - else: - logger.error('Got unsupported command %s from text %s', - command, text) - - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) + hass.http.register_view(ConversationProcessView) + return True + + +def _create_matcher(utterance): + """Create a regex that matches the utterance.""" + parts = re.split(r'({\w+})', utterance) + group_matcher = re.compile(r'{(\w+)}') + + pattern = ['^'] + + for part in parts: + match = group_matcher.match(part) + + if match is None: + pattern.append(part) + continue + + pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) + + +@asyncio.coroutine +def _process(hass, text): + """Process a line of text.""" + intents = hass.data.get(DOMAIN, {}) + + for intent_type, matchers in intents.items(): + for matcher in matchers: + match = matcher.match(text) + + if not match: + continue + + response = yield from intent.async_handle( + hass, DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response + + from fuzzywuzzy import process as fuzzyExtract + text = text.lower() + match = REGEX_TURN_COMMAND.match(text) + + if not match: + _LOGGER.error("Unable to process: %s", text) + return None + + name, command = match.groups() + entities = {state.entity_id: state.name for state + in hass.states.async_all()} + entity_ids = fuzzyExtract.extractOne( + name, entities, score_cutoff=65)[2] + + if not entity_ids: + _LOGGER.error( + "Could not find entity id %s from text %s", name, text) + return None + + if command == 'on': + yield from hass.services.async_call( + core.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) + + elif command == 'off': + yield from hass.services.async_call( + core.DOMAIN, SERVICE_TURN_OFF, { + ATTR_ENTITY_ID: entity_ids, + }, blocking=True) + + else: + _LOGGER.error('Got unsupported command %s from text %s', + command, text) + + return None + + +class ConversationProcessView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/conversation/process' + name = "api:conversation:process" + + @asyncio.coroutine + def post(self, request): + """Send a request for processing.""" + hass = request.app['hass'] + try: + data = yield from request.json() + except ValueError: + return self.json_message('Invalid JSON specified', + HTTP_BAD_REQUEST) + + text = data.get('text') + + if text is None: + return self.json_message('Missing "text" key in JSON.', + HTTP_BAD_REQUEST) + + intent_result = yield from _process(hass, text) + + if intent_result is None: + intent_result = intent.IntentResponse() + intent_result.async_set_speech("Sorry, I didn't understand that") + + return self.json(intent_result) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d323ad324c7..23c0be1a43e 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -13,6 +13,7 @@ import os import voluptuous as vol from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -22,7 +23,7 @@ from homeassistant.const import ( SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT, SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN, - STATE_CLOSED, STATE_UNKNOWN, ATTR_ENTITY_ID) + STATE_CLOSED, STATE_UNKNOWN, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID) _LOGGER = logging.getLogger(__name__) @@ -86,24 +87,28 @@ SERVICE_TO_METHOD = { } +@bind_hass def is_closed(hass, entity_id=None): """Return if the cover is closed based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_COVERS return hass.states.is_state(entity_id, STATE_CLOSED) +@bind_hass def open_cover(hass, entity_id=None): """Open all or specified cover.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_OPEN_COVER, data) +@bind_hass def close_cover(hass, entity_id=None): """Close all or specified cover.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, data) +@bind_hass def set_cover_position(hass, position, entity_id=None): """Move to specific position all or specified cover.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -111,24 +116,28 @@ def set_cover_position(hass, position, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, data) +@bind_hass def stop_cover(hass, entity_id=None): """Stop all or specified cover.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_STOP_COVER, data) +@bind_hass def open_cover_tilt(hass, entity_id=None): """Open all or specified cover tilt.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_OPEN_COVER_TILT, data) +@bind_hass def close_cover_tilt(hass, entity_id=None): """Close all or specified cover tilt.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_CLOSE_COVER_TILT, data) +@bind_hass def set_cover_tilt_position(hass, tilt_position, entity_id=None): """Move to specific tilt position all or specified cover.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -136,6 +145,7 @@ def set_cover_tilt_position(hass, tilt_position, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data) +@bind_hass def stop_cover_tilt(hass, entity_id=None): """Stop all or specified cover tilt.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None @@ -215,6 +225,11 @@ class CoverDevice(Entity): @property def state(self): """Return the state of the cover.""" + if self.is_opening: + return STATE_OPENING + if self.is_closing: + return STATE_CLOSING + closed = self.is_closed if closed is None: @@ -252,6 +267,16 @@ class CoverDevice(Entity): return supported_features + @property + def is_opening(self): + """Return if the cover is opening or not.""" + pass + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + pass + @property def is_closed(self): """Return if the cover is closed or not.""" diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index ed060659746..827b50c8af9 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -35,10 +35,12 @@ class DemoCover(CoverDevice): self._set_position = None self._set_tilt_position = None self._tilt_position = tilt_position - self._closing = True - self._closing_tilt = True + self._requested_closing = True + self._requested_closing_tilt = True self._unsub_listener_cover = None self._unsub_listener_cover_tilt = None + self._is_opening = False + self._is_closing = False if position is None: self._closed = True else: @@ -69,6 +71,16 @@ class DemoCover(CoverDevice): """Return if the cover is closed.""" return self._closed + @property + def is_closing(self): + """Return if the cover is closing.""" + return self._is_closing + + @property + def is_opening(self): + """Return if the cover is opening.""" + return self._is_opening + @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" @@ -90,8 +102,10 @@ class DemoCover(CoverDevice): self.schedule_update_ha_state() return + self._is_closing = True self._listen_cover() - self._closing = True + self._requested_closing = True + self.schedule_update_ha_state() def close_cover_tilt(self, **kwargs): """Close the cover tilt.""" @@ -99,7 +113,7 @@ class DemoCover(CoverDevice): return self._listen_cover_tilt() - self._closing_tilt = True + self._requested_closing_tilt = True def open_cover(self, **kwargs): """Open the cover.""" @@ -110,8 +124,10 @@ class DemoCover(CoverDevice): self.schedule_update_ha_state() return + self._is_opening = True self._listen_cover() - self._closing = False + self._requested_closing = False + self.schedule_update_ha_state() def open_cover_tilt(self, **kwargs): """Open the cover tilt.""" @@ -119,7 +135,7 @@ class DemoCover(CoverDevice): return self._listen_cover_tilt() - self._closing_tilt = False + self._requested_closing_tilt = False def set_cover_position(self, position, **kwargs): """Move the cover to a specific position.""" @@ -128,7 +144,7 @@ class DemoCover(CoverDevice): return self._listen_cover() - self._closing = position < self._position + self._requested_closing = position < self._position def set_cover_tilt_position(self, tilt_position, **kwargs): """Move the cover til to a specific position.""" @@ -137,10 +153,12 @@ class DemoCover(CoverDevice): return self._listen_cover_tilt() - self._closing_tilt = tilt_position < self._tilt_position + self._requested_closing_tilt = tilt_position < self._tilt_position def stop_cover(self, **kwargs): """Stop the cover.""" + self._is_closing = False + self._is_opening = False if self._position is None: return if self._unsub_listener_cover is not None: @@ -166,7 +184,7 @@ class DemoCover(CoverDevice): def _time_changed_cover(self, now): """Track time changes.""" - if self._closing: + if self._requested_closing: self._position -= 10 else: self._position += 10 @@ -186,7 +204,7 @@ class DemoCover(CoverDevice): def _time_changed_cover_tilt(self, now): """Track time changes.""" - if self._closing_tilt: + if self._requested_closing_tilt: self._tilt_position -= 10 else: self._tilt_position += 10 diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 2c411c61ba4..6857aaebf9b 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -23,7 +23,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron Caseta Serena shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] - cover_devices = bridge.get_devices_by_types(["SerenaRollerShade"]) + cover_devices = bridge.get_devices_by_types(["SerenaRollerShade", + "SerenaHoneycombShade"]) for cover_device in cover_devices: dev = LutronCasetaCover(cover_device, bridge) devs.append(dev) diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 4c862f8c8b8..8d59a90278c 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -12,7 +12,6 @@ from homeassistant.components.cover import CoverDevice from homeassistant.const import ( CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, STATE_CLOSED) import homeassistant.helpers.config_validation as cv -import homeassistant.loader as loader REQUIREMENTS = ['pymyq==0.0.8'] @@ -37,7 +36,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) brand = config.get(CONF_TYPE) - persistent_notification = loader.get_component('persistent_notification') myq = pymyq(username, password, brand) try: @@ -52,8 +50,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) - persistent_notification.create( - hass, 'Error: {}
' + hass.components.persistent_notification.create( + 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(ex), title=NOTIFICATION_TITLE, diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py new file mode 100644 index 00000000000..ab5d6e8ef79 --- /dev/null +++ b/homeassistant/components/cover/velbus.py @@ -0,0 +1,160 @@ +""" +Support for Velbus covers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.velbus/ +""" +import logging +import asyncio +import time + +import voluptuous as vol + +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE, + SUPPORT_STOP) +from homeassistant.components.velbus import DOMAIN +from homeassistant.const import (CONF_COVERS, CONF_NAME) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +COVER_SCHEMA = vol.Schema({ + vol.Required('module'): cv.positive_int, + vol.Required('open_channel'): cv.positive_int, + vol.Required('close_channel'): cv.positive_int, + vol.Required(CONF_NAME): cv.string +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}), +}) + +DEPENDENCIES = ['velbus'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up cover controlled by Velbus.""" + devices = config.get(CONF_COVERS, {}) + covers = [] + + velbus = hass.data[DOMAIN] + for device_name, device_config in devices.items(): + covers.append( + VelbusCover( + velbus, + device_config.get(CONF_NAME, device_name), + device_config.get('module'), + device_config.get('open_channel'), + device_config.get('close_channel') + ) + ) + + if not covers: + _LOGGER.error("No covers added") + return False + + add_devices(covers) + + +class VelbusCover(CoverDevice): + """Representation a Velbus cover.""" + + def __init__(self, velbus, name, module, open_channel, close_channel): + """Initialize the cover.""" + self._velbus = velbus + self._name = name + self._close_channel_state = None + self._open_channel_state = None + self._module = module + self._open_channel = open_channel + self._close_channel = close_channel + + @asyncio.coroutine + def async_added_to_hass(self): + """Add listener for Velbus messages on bus.""" + def _init_velbus(): + """Initialize Velbus on startup.""" + self._velbus.subscribe(self._on_message) + self.get_status() + + yield from self.hass.async_add_job(_init_velbus) + + def _on_message(self, message): + import velbus + if isinstance(message, velbus.RelayStatusMessage): + if message.address == self._module: + if message.channel == self._close_channel: + self._close_channel_state = message.is_on() + self.schedule_update_ha_state() + if message.channel == self._open_channel: + self._open_channel_state = message.is_on() + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self._close_channel_state + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown. + """ + return None + + def _relay_off(self, channel): + import velbus + message = velbus.SwitchRelayOffMessage() + message.set_defaults(self._module) + message.relay_channels = [channel] + self._velbus.send(message) + + def _relay_on(self, channel): + import velbus + message = velbus.SwitchRelayOnMessage() + message.set_defaults(self._module) + message.relay_channels = [channel] + self._velbus.send(message) + + def open_cover(self, **kwargs): + """Open the cover.""" + self._relay_off(self._close_channel) + time.sleep(0.3) + self._relay_on(self._open_channel) + + def close_cover(self, **kwargs): + """Close the cover.""" + self._relay_off(self._open_channel) + time.sleep(0.3) + self._relay_on(self._close_channel) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self._relay_off(self._open_channel) + time.sleep(0.3) + self._relay_off(self._close_channel) + + def get_status(self): + """Retrieve current status.""" + import velbus + message = velbus.ModuleStatusRequestMessage() + message.set_defaults(self._module) + message.channels = [self._open_channel, self._close_channel] + self._velbus.send(message) diff --git a/homeassistant/components/cover/xiaomi.py b/homeassistant/components/cover/xiaomi.py new file mode 100644 index 00000000000..7e3b0b7044d --- /dev/null +++ b/homeassistant/components/cover/xiaomi.py @@ -0,0 +1,66 @@ +"""Support for Xiaomi curtain.""" +import logging + +from homeassistant.components.cover import CoverDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +ATTR_CURTAIN_LEVEL = 'curtain_level' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['cover']: + model = device['model'] + if model == 'curtain': + devices.append(XiaomiGenericCover(device, "Curtain", + {'status': 'status', + 'pos': 'curtain_level'}, + gateway)) + add_devices(devices) + + +class XiaomiGenericCover(XiaomiDevice, CoverDevice): + """Representation of a XiaomiPlug.""" + + def __init__(self, device, name, data_key, xiaomi_hub): + """Initialize the XiaomiPlug.""" + self._data_key = data_key + self._pos = 0 + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def current_cover_position(self): + """Return the current position of the cover.""" + return self._pos + + @property + def is_closed(self): + """Return if the cover is closed.""" + return self.current_cover_position < 0 + + def close_cover(self, **kwargs): + """Close the cover.""" + 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') + + def stop_cover(self, **kwargs): + """Stop the cover.""" + 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)) + + def parse_data(self, data): + """Parse data sent by gateway.""" + if ATTR_CURTAIN_LEVEL in data: + self._pos = int(data[ATTR_CURTAIN_LEVEL]) + return True + return False diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 6ec70d9a85a..b8daab81503 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -110,24 +110,36 @@ class ZwaveGarageDoor(zwave.ZWaveDeviceEntity, CoverDevice): def __init__(self, values): """Initialize the zwave garage door.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) + self._state = None self.update_properties() def update_properties(self): """Handle data changes for node values.""" self._state = self.values.primary.data + _LOGGER.debug("self._state=%s", self._state) + + @property + def is_opening(self): + """Return true if cover is in an opening state.""" + return self._state == "Opening" + + @property + def is_closing(self): + """Return true if cover is in an closing state.""" + return self._state == "Closing" @property def is_closed(self): """Return the current position of Zwave garage door.""" - return not self._state + return self._state == "Closed" def close_cover(self): """Close the garage door.""" - self.values.primary.data = False + self.values.primary.data = "Closed" def open_cover(self): """Open the garage door.""" - self.values.primary.data = True + self.values.primary.data = "Opened" @property def device_class(self): diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 222a031d380..187e899aacd 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -9,7 +9,6 @@ import time import homeassistant.bootstrap as bootstrap import homeassistant.core as ha -import homeassistant.loader as loader from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM DEPENDENCIES = ['conversation', 'introduction', 'zone'] @@ -38,9 +37,9 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ @asyncio.coroutine def async_setup(hass, config): """Set up the demo environment.""" - group = loader.get_component('group') - configurator = loader.get_component('configurator') - persistent_notification = loader.get_component('persistent_notification') + group = hass.components.group + configurator = hass.components.configurator + persistent_notification = hass.components.persistent_notification config.setdefault(ha.DOMAIN, {}) config.setdefault(DOMAIN, {}) @@ -108,7 +107,7 @@ def async_setup(hass, config): # Set up example persistent notification persistent_notification.async_create( - hass, 'This is an example of a persistent notification.', + 'This is an example of a persistent notification.', title='Example Notification') # Set up room groups @@ -206,7 +205,7 @@ def async_setup(hass, config): def setup_configurator(): """Set up a configurator.""" request_id = configurator.request_config( - hass, "Philips Hue", hue_configuration_callback, + "Philips Hue", hue_configuration_callback, description=("Press the button on the bridge to register Philips " "Hue with Home Assistant."), description_image="/static/images/config_philips_hue.jpg", diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 017bb723ee5..8192dfa751d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.components import group, zone from homeassistant.components.discovery import SERVICE_NETGEAR from homeassistant.config import load_yaml_config_file, async_log_exception @@ -93,6 +94,7 @@ DISCOVERY_PLATFORMS = { } +@bind_hass def is_on(hass: HomeAssistantType, entity_id: str=None): """Return the state if any or a specified device is home.""" entity = entity_id or ENTITY_ID_ALL_DEVICES diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py index 882df575385..64e1a60ad08 100644 --- a/homeassistant/components/device_tracker/actiontec.py +++ b/homeassistant/components/device_tracker/actiontec.py @@ -7,9 +7,7 @@ https://home-assistant.io/components/device_tracker.actiontec/ import logging import re import telnetlib -import threading from collections import namedtuple -from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -17,9 +15,6 @@ import homeassistant.util.dt as dt_util from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -54,7 +49,6 @@ class ActiontecDeviceScanner(DeviceScanner): self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() self.last_results = [] data = self.get_actiontec_data() self.success_init = data is not None @@ -74,7 +68,6 @@ class ActiontecDeviceScanner(DeviceScanner): return client.ip return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the router is up to date. @@ -84,16 +77,15 @@ class ActiontecDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - now = dt_util.now() - actiontec_data = self.get_actiontec_data() - if not actiontec_data: - return False - self.last_results = [Device(data['mac'], name, now) - for name, data in actiontec_data.items() - if data['timevalid'] > -60] - _LOGGER.info("Scan successful") - return True + now = dt_util.now() + actiontec_data = self.get_actiontec_data() + if not actiontec_data: + return False + self.last_results = [Device(data['mac'], name, now) + for name, data in actiontec_data.items() + if data['timevalid'] > -60] + _LOGGER.info("Scan successful") + return True def get_actiontec_data(self): """Retrieve data from Actiontec MI424WR and return parsed result.""" diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index bfb1588b323..cef5eabd901 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.aruba/ """ import logging import re -import threading -from datetime import timedelta import voluptuous as vol @@ -15,14 +13,11 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['pexpect==4.0.1'] -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - _DEVICES_REGEX = re.compile( r'(?P([^\s]+))\s+' + r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' + @@ -52,8 +47,6 @@ class ArubaDeviceScanner(DeviceScanner): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() - self.last_results = {} # Test the router is accessible. @@ -74,7 +67,6 @@ class ArubaDeviceScanner(DeviceScanner): return client['name'] return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the Aruba Access Point is up to date. @@ -83,13 +75,12 @@ class ArubaDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - data = self.get_aruba_data() - if not data: - return False + data = self.get_aruba_data() + if not data: + return False - self.last_results = data.values() - return True + self.last_results = data.values() + return True def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index b28d16cc4a1..9b214441ac9 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -8,9 +8,7 @@ import logging import re import socket import telnetlib -import threading from collections import namedtuple -from datetime import timedelta import voluptuous as vol @@ -18,7 +16,6 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pexpect==4.0.1'] @@ -32,8 +29,6 @@ CONF_SSH_KEY = 'ssh_key' DEFAULT_SSH_PORT = 22 -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - SECRET_GROUP = 'Password or SSH Key' PLATFORM_SCHEMA = vol.All( @@ -123,8 +118,6 @@ class AsusWrtDeviceScanner(DeviceScanner): self.password, self.mode == "ap") - self.lock = threading.Lock() - self.last_results = {} # Test the router is accessible. @@ -145,7 +138,6 @@ class AsusWrtDeviceScanner(DeviceScanner): return client['host'] return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the ASUSWRT router is up to date. @@ -154,19 +146,18 @@ class AsusWrtDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info('Checking Devices') - data = self.get_asuswrt_data() - if not data: - return False + _LOGGER.info('Checking Devices') + data = self.get_asuswrt_data() + if not data: + return False - active_clients = [client for client in data.values() if - client['status'] == 'REACHABLE' or - client['status'] == 'DELAY' or - client['status'] == 'STALE' or - client['status'] == 'IN_ASSOCLIST'] - self.last_results = active_clients - return True + active_clients = [client for client in data.values() if + client['status'] == 'REACHABLE' or + client['status'] == 'DELAY' or + client['status'] == 'STALE' or + client['status'] == 'IN_ASSOCLIST'] + self.last_results = active_clients + return True def get_asuswrt_data(self): """Retrieve data from ASUSWRT and return parsed result.""" diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py index 5c1a14b446b..a3b5bcac77c 100644 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.bt_home_hub_5/ """ import logging import re -import threading -from datetime import timedelta import xml.etree.ElementTree as ET import json from urllib.parse import unquote @@ -19,13 +17,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string }) @@ -46,11 +41,7 @@ class BTHomeHub5DeviceScanner(DeviceScanner): """Initialise the scanner.""" _LOGGER.info("Initialising BT Home Hub 5") self.host = config.get(CONF_HOST, '192.168.1.254') - - self.lock = threading.Lock() - self.last_results = {} - self.url = 'http://{}/nonAuth/home_status.xml'.format(self.host) # Test the router is accessible @@ -65,17 +56,15 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - with self.lock: - # If not initialised and not already scanned and not found. - if device not in self.last_results: - self._update_info() + # If not initialised and not already scanned and not found. + if device not in self.last_results: + self._update_info() - if not self.last_results: - return None + if not self.last_results: + return None - return self.last_results.get(device) + return self.last_results.get(device) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the BT Home Hub 5 is up to date. @@ -84,18 +73,17 @@ class BTHomeHub5DeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info("Scanning") - data = _get_homehub_data(self.url) + data = _get_homehub_data(self.url) - if not data: - _LOGGER.warning("Error scanning devices") - return False + if not data: + _LOGGER.warning("Error scanning devices") + return False - self.last_results = data + self.last_results = data - return True + return True def _get_homehub_data(url): diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index 99ed06de486..0978ba99593 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.cisco_ios/ """ import logging -from datetime import timedelta import voluptuous as vol @@ -14,9 +13,6 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \ CONF_PORT -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -65,7 +61,6 @@ class CiscoDeviceScanner(DeviceScanner): return self.last_results - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """ Ensure the information from the Cisco router is up to date. diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index 4f1efcdb27c..3d36a1b428c 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.ddwrt/ """ import logging import re -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -16,9 +14,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -50,8 +45,6 @@ class DdWrtDeviceScanner(DeviceScanner): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() - self.last_results = {} self.mac2name = {} @@ -69,68 +62,65 @@ class DdWrtDeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - with self.lock: - # If not initialised and not already scanned and not found. - if device not in self.mac2name: - url = 'http://{}/Status_Lan.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) + # If not initialised and not already scanned and not found. + if device not in self.mac2name: + url = 'http://{}/Status_Lan.live.asp'.format(self.host) + data = self.get_ddwrt_data(url) - if not data: - return None + if not data: + return None - dhcp_leases = data.get('dhcp_leases', None) + dhcp_leases = data.get('dhcp_leases', None) - if not dhcp_leases: - return None + if not dhcp_leases: + return None - # Remove leading and trailing quotes and spaces - cleaned_str = dhcp_leases.replace( - "\"", "").replace("\'", "").replace(" ", "") - elements = cleaned_str.split(',') - num_clients = int(len(elements) / 5) - self.mac2name = {} - for idx in range(0, num_clients): - # The data is a single array - # every 5 elements represents one host, the MAC - # is the third element and the name is the first. - mac_index = (idx * 5) + 2 - if mac_index < len(elements): - mac = elements[mac_index] - self.mac2name[mac] = elements[idx * 5] + # Remove leading and trailing quotes and spaces + cleaned_str = dhcp_leases.replace( + "\"", "").replace("\'", "").replace(" ", "") + elements = cleaned_str.split(',') + num_clients = int(len(elements) / 5) + self.mac2name = {} + for idx in range(0, num_clients): + # The data is a single array + # every 5 elements represents one host, the MAC + # is the third element and the name is the first. + mac_index = (idx * 5) + 2 + if mac_index < len(elements): + mac = elements[mac_index] + self.mac2name[mac] = elements[idx * 5] - return self.mac2name.get(device) + return self.mac2name.get(device) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the DD-WRT router is up to date. Return boolean if scanning successful. """ - with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info("Checking ARP") - url = 'http://{}/Status_Wireless.live.asp'.format(self.host) - data = self.get_ddwrt_data(url) + url = 'http://{}/Status_Wireless.live.asp'.format(self.host) + data = self.get_ddwrt_data(url) - if not data: - return False + if not data: + return False - self.last_results = [] + self.last_results = [] - active_clients = data.get('active_wireless', None) - if not active_clients: - return False + active_clients = data.get('active_wireless', None) + if not active_clients: + return False - # The DD-WRT UI uses its own data format and then - # regex's out values so this is done here too - # Remove leading and trailing single quotes. - clean_str = active_clients.strip().strip("'") - elements = clean_str.split("','") + # The DD-WRT UI uses its own data format and then + # regex's out values so this is done here too + # Remove leading and trailing single quotes. + clean_str = active_clients.strip().strip("'") + elements = clean_str.split("','") - self.last_results.extend(item for item in elements - if _MAC_REGEX.match(item)) + self.last_results.extend(item for item in elements + if _MAC_REGEX.match(item)) - return True + return True def get_ddwrt_data(self, url): """Retrieve data from DD-WRT and return parsed result.""" diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py index 25de0a35c82..5210329179f 100644 --- a/homeassistant/components/device_tracker/fritz.py +++ b/homeassistant/components/device_tracker/fritz.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.fritz/ """ import logging -from datetime import timedelta import voluptuous as vol @@ -13,12 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle REQUIREMENTS = ['fritzconnection==0.6.3'] -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - _LOGGER = logging.getLogger(__name__) CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. @@ -88,7 +84,6 @@ class FritzBoxScanner(DeviceScanner): return None return ret - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Retrieve latest information from the FRITZ!Box.""" if not self.success_init: diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 01f97eb6e42..196235f32f4 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.linksys_ap/ """ import base64 import logging -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -16,9 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL) -from homeassistant.util import Throttle -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) INTERFACES = 2 DEFAULT_TIMEOUT = 10 @@ -51,8 +47,6 @@ class LinksysAPDeviceScanner(object): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] self.verify_ssl = config[CONF_VERIFY_SSL] - - self.lock = threading.Lock() self.last_results = [] # Check if the access point is accessible @@ -76,24 +70,22 @@ class LinksysAPDeviceScanner(object): """ return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Check for connected devices.""" from bs4 import BeautifulSoup as BS - with self.lock: - _LOGGER.info("Checking Linksys AP") + _LOGGER.info("Checking Linksys AP") - self.last_results = [] - for interface in range(INTERFACES): - request = self._make_request(interface) - self.last_results.extend( - [x.find_all('td')[1].text - for x in BS(request.content, "html.parser") - .find_all(class_='section-row')] - ) + self.last_results = [] + for interface in range(INTERFACES): + request = self._make_request(interface) + self.last_results.extend( + [x.find_all('td')[1].text + for x in BS(request.content, "html.parser") + .find_all(class_='section-row')] + ) - return True + return True def _make_request(self, unit=0): # No, the '&&' is not a typo - this is expected by the web interface. diff --git a/homeassistant/components/device_tracker/linksys_smart.py b/homeassistant/components/device_tracker/linksys_smart.py index e71502ba5ee..4bcbb600b8b 100644 --- a/homeassistant/components/device_tracker/linksys_smart.py +++ b/homeassistant/components/device_tracker/linksys_smart.py @@ -1,7 +1,5 @@ """Support for Linksys Smart Wifi routers.""" import logging -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -10,9 +8,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) DEFAULT_TIMEOUT = 10 _LOGGER = logging.getLogger(__name__) @@ -36,8 +32,6 @@ class LinksysSmartWifiDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" self.host = config[CONF_HOST] - - self.lock = threading.Lock() self.last_results = {} # Check if the access point is accessible @@ -55,48 +49,46 @@ class LinksysSmartWifiDeviceScanner(DeviceScanner): """Return the name (if known) of the device.""" return self.last_results.get(mac) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Check for connected devices.""" - with self.lock: - _LOGGER.info("Checking Linksys Smart Wifi") + _LOGGER.info("Checking Linksys Smart Wifi") - self.last_results = {} - response = self._make_request() - if response.status_code != 200: - _LOGGER.error( - "Got HTTP status code %d when getting device list", - response.status_code) - return False - try: - data = response.json() - result = data["responses"][0] - devices = result["output"]["devices"] - for device in devices: - macs = device["knownMACAddresses"] - if not macs: - _LOGGER.warning( - "Skipping device without known MAC address") - continue - mac = macs[-1] - connections = device["connections"] - if not connections: - _LOGGER.debug("Device %s is not connected", mac) - continue + self.last_results = {} + response = self._make_request() + if response.status_code != 200: + _LOGGER.error( + "Got HTTP status code %d when getting device list", + response.status_code) + return False + try: + data = response.json() + result = data["responses"][0] + devices = result["output"]["devices"] + for device in devices: + macs = device["knownMACAddresses"] + if not macs: + _LOGGER.warning( + "Skipping device without known MAC address") + continue + mac = macs[-1] + connections = device["connections"] + if not connections: + _LOGGER.debug("Device %s is not connected", mac) + continue - name = None - for prop in device["properties"]: - if prop["name"] == "userDeviceName": - name = prop["value"] - if not name: - name = device.get("friendlyName", device["deviceID"]) + name = None + for prop in device["properties"]: + if prop["name"] == "userDeviceName": + name = prop["value"] + if not name: + name = device.get("friendlyName", device["deviceID"]) - _LOGGER.debug("Device %s is connected", mac) - self.last_results[mac] = name - except (KeyError, IndexError): - _LOGGER.exception("Router returned unexpected response") - return False - return True + _LOGGER.debug("Device %s is connected", mac) + self.last_results[mac] = name + except (KeyError, IndexError): + _LOGGER.exception("Router returned unexpected response") + return False + return True def _make_request(self): # Weirdly enough, this doesn't seem to require authentication diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index 24af81b281e..a4b826a009f 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -7,8 +7,6 @@ https://home-assistant.io/components/device_tracker.luci/ import json import logging import re -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -18,9 +16,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -55,12 +50,8 @@ class LuciDeviceScanner(DeviceScanner): self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") - self.lock = threading.Lock() - self.last_results = {} - self.refresh_token() - self.mac2name = None self.success_init = self.token is not None @@ -75,24 +66,22 @@ class LuciDeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - with self.lock: - if self.mac2name is None: - url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) - result = _req_json_rpc(url, 'get_all', 'dhcp', - params={'auth': self.token}) - if result: - hosts = [x for x in result.values() - if x['.type'] == 'host' and - 'mac' in x and 'name' in x] - mac2name_list = [ - (x['mac'].upper(), x['name']) for x in hosts] - self.mac2name = dict(mac2name_list) - else: - # Error, handled in the _req_json_rpc - return - return self.mac2name.get(device.upper(), None) + if self.mac2name is None: + url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) + result = _req_json_rpc(url, 'get_all', 'dhcp', + params={'auth': self.token}) + if result: + hosts = [x for x in result.values() + if x['.type'] == 'host' and + 'mac' in x and 'name' in x] + mac2name_list = [ + (x['mac'].upper(), x['name']) for x in hosts] + self.mac2name = dict(mac2name_list) + else: + # Error, handled in the _req_json_rpc + return + return self.mac2name.get(device.upper(), None) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the Luci router is up to date. @@ -101,31 +90,30 @@ class LuciDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info("Checking ARP") - url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) - - try: - result = _req_json_rpc(url, 'net.arptable', - params={'auth': self.token}) - except InvalidLuciTokenError: - _LOGGER.info("Refreshing token") - self.refresh_token() - return False - - if result: - self.last_results = [] - for device_entry in result: - # Check if the Flags for each device contain - # NUD_REACHABLE and if so, add it to last_results - if int(device_entry['Flags'], 16) & 0x2: - self.last_results.append(device_entry['HW address']) - - return True + url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) + try: + result = _req_json_rpc(url, 'net.arptable', + params={'auth': self.token}) + except InvalidLuciTokenError: + _LOGGER.info("Refreshing token") + self.refresh_token() return False + if result: + self.last_results = [] + for device_entry in result: + # Check if the Flags for each device contain + # NUD_REACHABLE and if so, add it to last_results + if int(device_entry['Flags'], 16) & 0x2: + self.last_results.append(device_entry['HW address']) + + return True + + return False + def _req_json_rpc(url, method, *args, **kwargs): """Perform one JSON RPC operation.""" diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index fc1918f08cc..4e43b6ac10d 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -5,25 +5,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.mikrotik/ """ import logging -import threading -from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import (CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, - CONF_PORT) -from homeassistant.util import Throttle +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) REQUIREMENTS = ['librouteros==1.0.2'] -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - MTK_DEFAULT_API_PORT = '8728' _LOGGER = logging.getLogger(__name__) @@ -54,12 +46,9 @@ class MikrotikScanner(DeviceScanner): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() - self.connected = False self.success_init = False self.client = None - self.wireless_exist = None self.success_init = self.connect_to_device() @@ -118,51 +107,48 @@ class MikrotikScanner(DeviceScanner): def get_device_name(self, mac): """Return the name of the given device or None if we don't know.""" - with self.lock: - return self.last_results.get(mac) + return self.last_results.get(mac) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - with self.lock: - if self.wireless_exist: - devices_tracker = 'wireless' - else: - devices_tracker = 'ip' + if self.wireless_exist: + devices_tracker = 'wireless' + else: + devices_tracker = 'ip' - _LOGGER.info( - "Loading %s devices from Mikrotik (%s) ...", - devices_tracker, - self.host + _LOGGER.info( + "Loading %s devices from Mikrotik (%s) ...", + devices_tracker, + self.host + ) + + device_names = self.client(cmd='/ip/dhcp-server/lease/getall') + if self.wireless_exist: + devices = self.client( + cmd='/interface/wireless/registration-table/getall' ) + else: + devices = device_names - device_names = self.client(cmd='/ip/dhcp-server/lease/getall') - if self.wireless_exist: - devices = self.client( - cmd='/interface/wireless/registration-table/getall' - ) - else: - devices = device_names + if device_names is None and devices is None: + return False - if device_names is None and devices is None: - return False + mac_names = {device.get('mac-address'): device.get('host-name') + for device in device_names + if device.get('mac-address')} - mac_names = {device.get('mac-address'): device.get('host-name') - for device in device_names - if device.get('mac-address')} + if self.wireless_exist: + self.last_results = { + device.get('mac-address'): + mac_names.get(device.get('mac-address')) + for device in devices + } + else: + self.last_results = { + device.get('mac-address'): + mac_names.get(device.get('mac-address')) + for device in device_names + if device.get('active-address') + } - if self.wireless_exist: - self.last_results = { - device.get('mac-address'): - mac_names.get(device.get('mac-address')) - for device in devices - } - else: - self.last_results = { - device.get('mac-address'): - mac_names.get(device.get('mac-address')) - for device in device_names - if device.get('active-address') - } - - return True + return True diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index b3ec442198e..d2b8bc274ca 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -5,8 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.netgear/ """ import logging -import threading -from datetime import timedelta import voluptuous as vol @@ -15,14 +13,11 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -from homeassistant.util import Throttle REQUIREMENTS = ['pynetgear==0.3.3'] _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - DEFAULT_HOST = 'routerlogin.net' DEFAULT_USER = 'admin' DEFAULT_PORT = 5000 @@ -56,8 +51,6 @@ class NetgearDeviceScanner(DeviceScanner): import pynetgear self.last_results = [] - self.lock = threading.Lock() - self._api = pynetgear.Netgear(password, host, username, port) _LOGGER.info("Logging in") @@ -85,7 +78,6 @@ class NetgearDeviceScanner(DeviceScanner): except StopIteration: return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Retrieve latest information from the Netgear router. @@ -94,12 +86,11 @@ class NetgearDeviceScanner(DeviceScanner): if not self.success_init: return - with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info("Scanning") - results = self._api.get_attached_devices() + results = self._api.get_attached_devices() - if results is None: - _LOGGER.warning("Error scanning devices") + if results is None: + _LOGGER.warning("Error scanning devices") - self.last_results = results or [] + self.last_results = results or [] diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 8a845adf0b8..e9d70142ad1 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -4,11 +4,11 @@ Support for scanning a network with nmap. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.nmap_tracker/ """ +from datetime import timedelta import logging import re import subprocess from collections import namedtuple -from datetime import timedelta import voluptuous as vol @@ -17,7 +17,6 @@ import homeassistant.util.dt as dt_util from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOSTS -from homeassistant.util import Throttle REQUIREMENTS = ['python-nmap==0.6.1'] @@ -29,8 +28,6 @@ CONF_HOME_INTERVAL = 'home_interval' CONF_OPTIONS = 'scan_options' DEFAULT_OPTIONS = '-F --host-timeout 5s' -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOSTS): cv.ensure_list, @@ -97,7 +94,6 @@ class NmapDeviceScanner(DeviceScanner): return filter_named[0] return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Scan the network for devices. diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index f88fda03cf7..b23008336ac 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -21,7 +21,7 @@ from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import PLATFORM_SCHEMA DEPENDENCIES = ['mqtt'] -REQUIREMENTS = ['libnacl==1.5.1'] +REQUIREMENTS = ['libnacl==1.5.2'] _LOGGER = logging.getLogger(__name__) @@ -353,12 +353,20 @@ def _parse_see_args(topic, data): kwargs = { 'dev_id': dev_id, 'host_name': host_name, - 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]) + 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]), + 'attributes': {} } if 'acc' in data: kwargs['gps_accuracy'] = data['acc'] if 'batt' in data: kwargs['battery'] = data['batt'] + if 'vel' in data: + kwargs['attributes']['velocity'] = data['vel'] + if 'tid' in data: + kwargs['attributes']['tid'] = data['tid'] + if 'addr' in data: + kwargs['attributes']['address'] = data['addr'] + return dev_id, kwargs diff --git a/homeassistant/components/device_tracker/sky_hub.py b/homeassistant/components/device_tracker/sky_hub.py index ef58c50991c..c48c9bd029b 100644 --- a/homeassistant/components/device_tracker/sky_hub.py +++ b/homeassistant/components/device_tracker/sky_hub.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.sky_hub/ """ import logging import re -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -16,13 +14,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string }) @@ -43,11 +38,7 @@ class SkyHubDeviceScanner(DeviceScanner): """Initialise the scanner.""" _LOGGER.info("Initialising Sky Hub") self.host = config.get(CONF_HOST, '192.168.1.254') - - self.lock = threading.Lock() - self.last_results = {} - self.url = 'http://{}/'.format(self.host) # Test the router is accessible @@ -62,17 +53,15 @@ class SkyHubDeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - with self.lock: - # If not initialised and not already scanned and not found. - if device not in self.last_results: - self._update_info() + # If not initialised and not already scanned and not found. + if device not in self.last_results: + self._update_info() - if not self.last_results: - return None + if not self.last_results: + return None - return self.last_results.get(device) + return self.last_results.get(device) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the Sky Hub is up to date. @@ -81,18 +70,17 @@ class SkyHubDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Scanning") + _LOGGER.info("Scanning") - data = _get_skyhub_data(self.url) + data = _get_skyhub_data(self.url) - if not data: - _LOGGER.warning('Error scanning devices') - return False + if not data: + _LOGGER.warning('Error scanning devices') + return False - self.last_results = data + self.last_results = data - return True + return True def _get_skyhub_data(url): diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index a3be40036cb..3efae2b9ce2 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -6,8 +6,6 @@ https://home-assistant.io/components/device_tracker.snmp/ """ import binascii import logging -import threading -from datetime import timedelta import voluptuous as vol @@ -15,11 +13,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pysnmp==4.3.8'] +REQUIREMENTS = ['pysnmp==4.3.9'] CONF_COMMUNITY = 'community' CONF_AUTHKEY = 'authkey' @@ -28,8 +25,6 @@ CONF_BASEOID = 'baseoid' DEFAULT_COMMUNITY = 'public' -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, @@ -68,9 +63,6 @@ class SnmpScanner(DeviceScanner): privProtocol=cfg.usmAesCfb128Protocol ) self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID]) - - self.lock = threading.Lock() - self.last_results = [] # Test the router is accessible @@ -90,7 +82,6 @@ class SnmpScanner(DeviceScanner): # We have no names return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the device is up to date. @@ -99,13 +90,12 @@ class SnmpScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - data = self.get_snmp_data() - if not data: - return False + data = self.get_snmp_data() + if not data: + return False - self.last_results = data - return True + self.last_results = data + return True def get_snmp_data(self): """Fetch MAC addresses from access point via SNMP.""" diff --git a/homeassistant/components/device_tracker/swisscom.py b/homeassistant/components/device_tracker/swisscom.py index d2a5a57e491..e64d30942ca 100644 --- a/homeassistant/components/device_tracker/swisscom.py +++ b/homeassistant/components/device_tracker/swisscom.py @@ -5,8 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.swisscom/ """ import logging -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -15,9 +13,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -41,9 +36,6 @@ class SwisscomDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" self.host = config[CONF_HOST] - - self.lock = threading.Lock() - self.last_results = {} # Test the router is accessible. @@ -64,7 +56,6 @@ class SwisscomDeviceScanner(DeviceScanner): return client['host'] return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the Swisscom router is up to date. @@ -73,16 +64,15 @@ class SwisscomDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Loading data from Swisscom Internet Box") - data = self.get_swisscom_data() - if not data: - return False + _LOGGER.info("Loading data from Swisscom Internet Box") + data = self.get_swisscom_data() + if not data: + return False - active_clients = [client for client in data.values() if - client['status']] - self.last_results = active_clients - return True + active_clients = [client for client in data.values() if + client['status']] + self.last_results = active_clients + return True def get_swisscom_data(self): """Retrieve data from Swisscom and return parsed result.""" diff --git a/homeassistant/components/device_tracker/thomson.py b/homeassistant/components/device_tracker/thomson.py index 6efe8d59beb..3fa161e467d 100644 --- a/homeassistant/components/device_tracker/thomson.py +++ b/homeassistant/components/device_tracker/thomson.py @@ -7,8 +7,6 @@ https://home-assistant.io/components/device_tracker.thomson/ import logging import re import telnetlib -import threading -from datetime import timedelta import voluptuous as vol @@ -16,9 +14,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) @@ -54,9 +49,6 @@ class ThomsonDeviceScanner(DeviceScanner): self.host = config[CONF_HOST] self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - - self.lock = threading.Lock() - self.last_results = {} # Test the router is accessible. @@ -77,7 +69,6 @@ class ThomsonDeviceScanner(DeviceScanner): return client['host'] return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the THOMSON router is up to date. @@ -86,17 +77,16 @@ class ThomsonDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Checking ARP") - data = self.get_thomson_data() - if not data: - return False + _LOGGER.info("Checking ARP") + data = self.get_thomson_data() + if not data: + return False - # Flag C stands for CONNECTED - active_clients = [client for client in data.values() if - client['status'].find('C') != -1] - self.last_results = active_clients - return True + # Flag C stands for CONNECTED + active_clients = [client for client in data.values() if + client['status'].find('C') != -1] + self.last_results = active_clients + return True def get_thomson_data(self): """Retrieve data from THOMSON and return parsed result.""" diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 0b330c933d8..57e83eaeb94 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -7,8 +7,6 @@ https://home-assistant.io/components/device_tracker.tomato/ import json import logging import re -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -17,9 +15,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) CONF_HTTP_ID = 'http_id' @@ -54,8 +49,6 @@ class TomatoDeviceScanner(DeviceScanner): self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato")) - self.lock = threading.Lock() - self.last_results = {"wldev": [], "dhcpd_lease": []} self.success_init = self._update_tomato_info() @@ -76,50 +69,48 @@ class TomatoDeviceScanner(DeviceScanner): return filter_named[0] - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_tomato_info(self): """Ensure the information from the Tomato router is up to date. Return boolean if scanning successful. """ - with self.lock: - self.logger.info("Scanning") + self.logger.info("Scanning") - try: - response = requests.Session().send(self.req, timeout=3) - # Calling and parsing the Tomato api here. We only need the - # wldev and dhcpd_lease values. - if response.status_code == 200: + try: + response = requests.Session().send(self.req, timeout=3) + # Calling and parsing the Tomato api here. We only need the + # wldev and dhcpd_lease values. + if response.status_code == 200: - for param, value in \ - self.parse_api_pattern.findall(response.text): + for param, value in \ + self.parse_api_pattern.findall(response.text): - if param == 'wldev' or param == 'dhcpd_lease': - self.last_results[param] = \ - json.loads(value.replace("'", '"')) - return True + if param == 'wldev' or param == 'dhcpd_lease': + self.last_results[param] = \ + json.loads(value.replace("'", '"')) + return True - elif response.status_code == 401: - # Authentication error - self.logger.exception(( - "Failed to authenticate, " - "please check your username and password")) - return False - - except requests.exceptions.ConnectionError: - # We get this if we could not connect to the router or - # an invalid http_id was supplied. - self.logger.exception("Failed to connect to the router or " - "invalid http_id supplied") + elif response.status_code == 401: + # Authentication error + self.logger.exception(( + "Failed to authenticate, " + "please check your username and password")) return False - except requests.exceptions.Timeout: - # We get this if we could not connect to the router or - # an invalid http_id was supplied. - self.logger.exception("Connection to the router timed out") - return False + except requests.exceptions.ConnectionError: + # We get this if we could not connect to the router or + # an invalid http_id was supplied. + self.logger.exception("Failed to connect to the router or " + "invalid http_id supplied") + return False - except ValueError: - # If JSON decoder could not parse the response. - self.logger.exception("Failed to parse response from router") - return False + except requests.exceptions.Timeout: + # We get this if we could not connect to the router or + # an invalid http_id was supplied. + self.logger.exception("Connection to the router timed out") + return False + + except ValueError: + # If JSON decoder could not parse the response. + self.logger.exception("Failed to parse response from router") + return False diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index ccf0c2d01af..a52de48d061 100755 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -8,8 +8,7 @@ import base64 import hashlib import logging import re -import threading -from datetime import timedelta, datetime +from datetime import datetime import requests import voluptuous as vol @@ -18,9 +17,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle - -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) _LOGGER = logging.getLogger(__name__) @@ -59,7 +55,6 @@ class TplinkDeviceScanner(DeviceScanner): self.password = password self.last_results = {} - self.lock = threading.Lock() self.success_init = self._update_info() def scan_devices(self): @@ -72,28 +67,26 @@ class TplinkDeviceScanner(DeviceScanner): """Get firmware doesn't save the name of the wireless device.""" return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ - with self.lock: - _LOGGER.info("Loading wireless clients...") + _LOGGER.info("Loading wireless clients...") - url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host) - referer = 'http://{}'.format(self.host) - page = requests.get( - url, auth=(self.username, self.password), - headers={'referer': referer}, timeout=4) + url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host) + referer = 'http://{}'.format(self.host) + page = requests.get( + url, auth=(self.username, self.password), + headers={'referer': referer}, timeout=4) - result = self.parse_macs.findall(page.text) + result = self.parse_macs.findall(page.text) - if result: - self.last_results = [mac.replace("-", ":") for mac in result] - return True + if result: + self.last_results = [mac.replace("-", ":") for mac in result] + return True - return False + return False class Tplink2DeviceScanner(TplinkDeviceScanner): @@ -109,48 +102,46 @@ class Tplink2DeviceScanner(TplinkDeviceScanner): """Get firmware doesn't save the name of the wireless device.""" return self.last_results.get(device) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ - with self.lock: - _LOGGER.info("Loading wireless clients...") + _LOGGER.info("Loading wireless clients...") - url = 'http://{}/data/map_access_wireless_client_grid.json' \ - .format(self.host) - referer = 'http://{}'.format(self.host) + url = 'http://{}/data/map_access_wireless_client_grid.json' \ + .format(self.host) + referer = 'http://{}'.format(self.host) - # Router uses Authorization cookie instead of header - # Let's create the cookie - username_password = '{}:{}'.format(self.username, self.password) - b64_encoded_username_password = base64.b64encode( - username_password.encode('ascii') - ).decode('ascii') - cookie = 'Authorization=Basic {}' \ - .format(b64_encoded_username_password) + # Router uses Authorization cookie instead of header + # Let's create the cookie + username_password = '{}:{}'.format(self.username, self.password) + b64_encoded_username_password = base64.b64encode( + username_password.encode('ascii') + ).decode('ascii') + cookie = 'Authorization=Basic {}' \ + .format(b64_encoded_username_password) - response = requests.post( - url, headers={'referer': referer, 'cookie': cookie}, - timeout=4) - - try: - result = response.json().get('data') - except ValueError: - _LOGGER.error("Router didn't respond with JSON. " - "Check if credentials are correct.") - return False - - if result: - self.last_results = { - device['mac_addr'].replace('-', ':'): device['name'] - for device in result - } - return True + response = requests.post( + url, headers={'referer': referer, 'cookie': cookie}, + timeout=4) + try: + result = response.json().get('data') + except ValueError: + _LOGGER.error("Router didn't respond with JSON. " + "Check if credentials are correct.") return False + if result: + self.last_results = { + device['mac_addr'].replace('-', ':'): device['name'] + for device in result + } + return True + + return False + class Tplink3DeviceScanner(TplinkDeviceScanner): """This class queries the Archer C9 router with version 150811 or high.""" @@ -202,70 +193,67 @@ class Tplink3DeviceScanner(TplinkDeviceScanner): response.text) return False - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ - with self.lock: - if (self.stok == '') or (self.sysauth == ''): - self._get_auth_tokens() + if (self.stok == '') or (self.sysauth == ''): + self._get_auth_tokens() - _LOGGER.info("Loading wireless clients...") + _LOGGER.info("Loading wireless clients...") - url = ('http://{}/cgi-bin/luci/;stok={}/admin/wireless?' - 'form=statistics').format(self.host, self.stok) - referer = 'http://{}/webpages/index.html'.format(self.host) + url = ('http://{}/cgi-bin/luci/;stok={}/admin/wireless?' + 'form=statistics').format(self.host, self.stok) + referer = 'http://{}/webpages/index.html'.format(self.host) - response = requests.post(url, - params={'operation': 'load'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}, - timeout=5) + response = requests.post(url, + params={'operation': 'load'}, + headers={'referer': referer}, + cookies={'sysauth': self.sysauth}, + timeout=5) - try: - json_response = response.json() + try: + json_response = response.json() - if json_response.get('success'): - result = response.json().get('data') - else: - if json_response.get('errorcode') == 'timeout': - _LOGGER.info("Token timed out. Relogging on next scan") - self.stok = '' - self.sysauth = '' - return False - _LOGGER.error( - "An unknown error happened while fetching data") + if json_response.get('success'): + result = response.json().get('data') + else: + if json_response.get('errorcode') == 'timeout': + _LOGGER.info("Token timed out. Relogging on next scan") + self.stok = '' + self.sysauth = '' return False - except ValueError: - _LOGGER.error("Router didn't respond with JSON. " - "Check if credentials are correct") + _LOGGER.error( + "An unknown error happened while fetching data") return False - - if result: - self.last_results = { - device['mac'].replace('-', ':'): device['mac'] - for device in result - } - return True - + except ValueError: + _LOGGER.error("Router didn't respond with JSON. " + "Check if credentials are correct") return False + if result: + self.last_results = { + device['mac'].replace('-', ':'): device['mac'] + for device in result + } + return True + + return False + def _log_out(self): - with self.lock: - _LOGGER.info("Logging out of router admin interface...") + _LOGGER.info("Logging out of router admin interface...") - url = ('http://{}/cgi-bin/luci/;stok={}/admin/system?' - 'form=logout').format(self.host, self.stok) - referer = 'http://{}/webpages/index.html'.format(self.host) + url = ('http://{}/cgi-bin/luci/;stok={}/admin/system?' + 'form=logout').format(self.host, self.stok) + referer = 'http://{}/webpages/index.html'.format(self.host) - requests.post(url, - params={'operation': 'write'}, - headers={'referer': referer}, - cookies={'sysauth': self.sysauth}) - self.stok = '' - self.sysauth = '' + requests.post(url, + params={'operation': 'write'}, + headers={'referer': referer}, + cookies={'sysauth': self.sysauth}) + self.stok = '' + self.sysauth = '' class Tplink4DeviceScanner(TplinkDeviceScanner): @@ -318,38 +306,36 @@ class Tplink4DeviceScanner(TplinkDeviceScanner): _LOGGER.error("Couldn't fetch auth tokens") return False - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the TP-Link router is up to date. Return boolean if scanning successful. """ - with self.lock: - if (self.credentials == '') or (self.token == ''): - self._get_auth_tokens() + if (self.credentials == '') or (self.token == ''): + self._get_auth_tokens() - _LOGGER.info("Loading wireless clients...") + _LOGGER.info("Loading wireless clients...") - mac_results = [] + mac_results = [] - # Check both the 2.4GHz and 5GHz client list URLs - for clients_url in ('WlanStationRpm.htm', 'WlanStationRpm_5g.htm'): - url = 'http://{}/{}/userRpm/{}' \ - .format(self.host, self.token, clients_url) - referer = 'http://{}'.format(self.host) - cookie = 'Authorization=Basic {}'.format(self.credentials) + # Check both the 2.4GHz and 5GHz client list URLs + for clients_url in ('WlanStationRpm.htm', 'WlanStationRpm_5g.htm'): + url = 'http://{}/{}/userRpm/{}' \ + .format(self.host, self.token, clients_url) + referer = 'http://{}'.format(self.host) + cookie = 'Authorization=Basic {}'.format(self.credentials) - page = requests.get(url, headers={ - 'cookie': cookie, - 'referer': referer - }) - mac_results.extend(self.parse_macs.findall(page.text)) + page = requests.get(url, headers={ + 'cookie': cookie, + 'referer': referer + }) + mac_results.extend(self.parse_macs.findall(page.text)) - if not mac_results: - return False + if not mac_results: + return False - self.last_results = [mac.replace("-", ":") for mac in mac_results] - return True + self.last_results = [mac.replace("-", ":") for mac in mac_results] + return True class Tplink5DeviceScanner(TplinkDeviceScanner): @@ -365,69 +351,67 @@ class Tplink5DeviceScanner(TplinkDeviceScanner): """Get firmware doesn't save the name of the wireless device.""" return None - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the TP-Link AP is up to date. Return boolean if scanning successful. """ - with self.lock: - _LOGGER.info("Loading wireless clients...") + _LOGGER.info("Loading wireless clients...") - base_url = 'http://{}'.format(self.host) + base_url = 'http://{}'.format(self.host) - header = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" - " rv:53.0) Gecko/20100101 Firefox/53.0", - "Accept": "application/json, text/javascript, */*; q=0.01", - "Accept-Language": "Accept-Language: en-US,en;q=0.5", - "Accept-Encoding": "gzip, deflate", - "Content-Type": "application/x-www-form-urlencoded; " - "charset=UTF-8", - "X-Requested-With": "XMLHttpRequest", - "Referer": "http://" + self.host + "/", - "Connection": "keep-alive", - "Pragma": "no-cache", - "Cache-Control": "no-cache" - } + header = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;" + " rv:53.0) Gecko/20100101 Firefox/53.0", + "Accept": "application/json, text/javascript, */*; q=0.01", + "Accept-Language": "Accept-Language: en-US,en;q=0.5", + "Accept-Encoding": "gzip, deflate", + "Content-Type": "application/x-www-form-urlencoded; " + "charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Referer": "http://" + self.host + "/", + "Connection": "keep-alive", + "Pragma": "no-cache", + "Cache-Control": "no-cache" + } - password_md5 = hashlib.md5( - self.password.encode('utf')).hexdigest().upper() + password_md5 = hashlib.md5( + self.password.encode('utf')).hexdigest().upper() - # create a session to handle cookie easier - session = requests.session() - session.get(base_url, headers=header) + # create a session to handle cookie easier + session = requests.session() + session.get(base_url, headers=header) - login_data = {"username": self.username, "password": password_md5} - session.post(base_url, login_data, headers=header) + login_data = {"username": self.username, "password": password_md5} + session.post(base_url, login_data, headers=header) - # a timestamp is required to be sent as get parameter - timestamp = int(datetime.now().timestamp() * 1e3) + # a timestamp is required to be sent as get parameter + timestamp = int(datetime.now().timestamp() * 1e3) - client_list_url = '{}/data/monitor.client.client.json'.format( - base_url) + client_list_url = '{}/data/monitor.client.client.json'.format( + base_url) - get_params = { - 'operation': 'load', - '_': timestamp - } - - response = session.get(client_list_url, - headers=header, - params=get_params) - session.close() - try: - list_of_devices = response.json() - except ValueError: - _LOGGER.error("AP didn't respond with JSON. " - "Check if credentials are correct.") - return False - - if list_of_devices: - self.last_results = { - device['MAC'].replace('-', ':'): device['DeviceName'] - for device in list_of_devices['data'] - } - return True + get_params = { + 'operation': 'load', + '_': timestamp + } + response = session.get(client_list_url, + headers=header, + params=get_params) + session.close() + try: + list_of_devices = response.json() + except ValueError: + _LOGGER.error("AP didn't respond with JSON. " + "Check if credentials are correct.") return False + + if list_of_devices: + self.last_results = { + device['MAC'].replace('-', ':'): device['DeviceName'] + for device in list_of_devices['data'] + } + return True + + return False diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 8d4cd1dcd73..9ccc61dffc9 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -7,8 +7,6 @@ https://home-assistant.io/components/device_tracker.ubus/ import json import logging import re -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -17,12 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle from homeassistant.exceptions import HomeAssistantError -# Return cached results if last scan was less then this time ago. -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -70,7 +64,6 @@ class UbusDeviceScanner(DeviceScanner): self.password = config[CONF_PASSWORD] self.parse_api_pattern = re.compile(r"(?P\w*) = (?P.*);") - self.lock = threading.Lock() self.last_results = {} self.url = 'http://{}/ubus'.format(host) @@ -87,36 +80,34 @@ class UbusDeviceScanner(DeviceScanner): return self.last_results @_refresh_on_acccess_denied - def get_device_name(self, device): + def get_device_name(self, mac): """Return the name of the given device or None if we don't know.""" - with self.lock: - if self.leasefile is None: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'uci', 'get', - config="dhcp", type="dnsmasq") - if result: - values = result["values"].values() - self.leasefile = next(iter(values))["leasefile"] - else: - return + if self.leasefile is None: + result = _req_json_rpc( + self.url, self.session_id, 'call', 'uci', 'get', + config="dhcp", type="dnsmasq") + if result: + values = result["values"].values() + self.leasefile = next(iter(values))["leasefile"] + else: + return - if self.mac2name is None: - result = _req_json_rpc( - self.url, self.session_id, 'call', 'file', 'read', - path=self.leasefile) - if result: - self.mac2name = dict() - for line in result["data"].splitlines(): - hosts = line.split(" ") - self.mac2name[hosts[1].upper()] = hosts[3] - else: - # Error, handled in the _req_json_rpc - return + if self.mac2name is None: + result = _req_json_rpc( + self.url, self.session_id, 'call', 'file', 'read', + path=self.leasefile) + if result: + self.mac2name = dict() + for line in result["data"].splitlines(): + hosts = line.split(" ") + self.mac2name[hosts[1].upper()] = hosts[3] + else: + # Error, handled in the _req_json_rpc + return - return self.mac2name.get(device.upper(), None) + return self.mac2name.get(mac.upper(), None) @_refresh_on_acccess_denied - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the information from the Luci router is up to date. @@ -125,25 +116,24 @@ class UbusDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - _LOGGER.info("Checking ARP") + _LOGGER.info("Checking ARP") - if not self.hostapd: - hostapd = _req_json_rpc( - self.url, self.session_id, 'list', 'hostapd.*', '') - self.hostapd.extend(hostapd.keys()) + if not self.hostapd: + hostapd = _req_json_rpc( + self.url, self.session_id, 'list', 'hostapd.*', '') + self.hostapd.extend(hostapd.keys()) - self.last_results = [] - results = 0 - for hostapd in self.hostapd: - result = _req_json_rpc( - self.url, self.session_id, 'call', hostapd, 'get_clients') + self.last_results = [] + results = 0 + for hostapd in self.hostapd: + result = _req_json_rpc( + self.url, self.session_id, 'call', hostapd, 'get_clients') - if result: - results = results + 1 - self.last_results.extend(result['clients'].keys()) + if result: + results = results + 1 + self.last_results.extend(result['clients'].keys()) - return bool(results) + return bool(results) def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params): diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 29c997b4dac..a471ca5c96a 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -8,7 +8,6 @@ import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv -import homeassistant.loader as loader from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD @@ -48,14 +47,13 @@ def get_scanner(hass, config): port = config[DOMAIN].get(CONF_PORT) verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL) - persistent_notification = loader.get_component('persistent_notification') try: ctrl = Controller(host, username, password, port, version='v4', site_id=site_id, ssl_verify=verify_ssl) except APIError as ex: _LOGGER.error("Failed to connect to Unifi: %s", ex) - persistent_notification.create( - hass, 'Failed to connect to Unifi. ' + hass.components.persistent_notification.create( + 'Failed to connect to Unifi. ' 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(ex), diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 9bd5727510a..4312c5dd54a 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -9,8 +9,7 @@ import logging from homeassistant.util import slugify from homeassistant.helpers.dispatcher import ( dispatcher_connect, dispatcher_send) -from homeassistant.components.volvooncall import ( - DATA_KEY, SIGNAL_VEHICLE_SEEN) +from homeassistant.components.volvooncall import DATA_KEY, SIGNAL_VEHICLE_SEEN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/xiaomi.py b/homeassistant/components/device_tracker/xiaomi.py index a7b0a1ad326..8b8db3da2d8 100644 --- a/homeassistant/components/device_tracker/xiaomi.py +++ b/homeassistant/components/device_tracker/xiaomi.py @@ -5,8 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.xiaomi/ """ import logging -import threading -from datetime import timedelta import requests import voluptuous as vol @@ -15,12 +13,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME, default='admin'): cv.string, @@ -47,8 +42,6 @@ class XiaomiDeviceScanner(DeviceScanner): self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] - self.lock = threading.Lock() - self.last_results = {} self.token = _get_token(self.host, self.username, self.password) @@ -62,21 +55,19 @@ class XiaomiDeviceScanner(DeviceScanner): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - with self.lock: - if self.mac2name is None: - result = self._retrieve_list_with_retry() - if result: - hosts = [x for x in result - if 'mac' in x and 'name' in x] - mac2name_list = [ - (x['mac'].upper(), x['name']) for x in hosts] - self.mac2name = dict(mac2name_list) - else: - # Error, handled in the _retrieve_list_with_retry - return - return self.mac2name.get(device.upper(), None) + if self.mac2name is None: + result = self._retrieve_list_with_retry() + if result: + hosts = [x for x in result + if 'mac' in x and 'name' in x] + mac2name_list = [ + (x['mac'].upper(), x['name']) for x in hosts] + self.mac2name = dict(mac2name_list) + else: + # Error, handled in the _retrieve_list_with_retry + return + return self.mac2name.get(device.upper(), None) - @Throttle(MIN_TIME_BETWEEN_SCANS) def _update_info(self): """Ensure the informations from the router are up to date. @@ -85,12 +76,11 @@ class XiaomiDeviceScanner(DeviceScanner): if not self.success_init: return False - with self.lock: - result = self._retrieve_list_with_retry() - if result: - self._store_result(result) - return True - return False + result = self._retrieve_list_with_retry() + if result: + self._store_result(result) + return True + return False def _retrieve_list_with_retry(self): """Retrieve the device list with a retry if token is invalid. diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 3dfe4b9731c..af4604cb7d7 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.0.1'] +REQUIREMENTS = ['netdisco==1.1.0'] DOMAIN = 'discovery' diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index b0b6ec0324f..315fc564999 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -193,7 +193,7 @@ class Config(object): if entity_id == ent_id: return number - number = str(len(self.numbers) + 1) + number = str(max(int(k) for k in self.numbers) + 1) self.numbers[number] = entity_id self._save_numbers_json() return number diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 4642017ce32..fd12529cb48 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -17,6 +17,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE, SERVICE_TURN_OFF, ATTR_ENTITY_ID, STATE_UNKNOWN) +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -118,6 +119,7 @@ SERVICE_TO_METHOD = { } +@bind_hass def is_on(hass, entity_id: str=None) -> bool: """Return if the fans are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_FANS @@ -125,6 +127,7 @@ def is_on(hass, entity_id: str=None) -> bool: return state.attributes[ATTR_SPEED] not in [SPEED_OFF, STATE_UNKNOWN] +@bind_hass def turn_on(hass, entity_id: str=None, speed: str=None) -> None: """Turn all or specified fan on.""" data = { @@ -137,6 +140,7 @@ def turn_on(hass, entity_id: str=None, speed: str=None) -> None: hass.services.call(DOMAIN, SERVICE_TURN_ON, data) +@bind_hass def turn_off(hass, entity_id: str=None) -> None: """Turn all or specified fan off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -144,6 +148,7 @@ def turn_off(hass, entity_id: str=None) -> None: hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) +@bind_hass def toggle(hass, entity_id: str=None) -> None: """Toggle all or specified fans.""" data = { @@ -153,6 +158,7 @@ def toggle(hass, entity_id: str=None) -> None: hass.services.call(DOMAIN, SERVICE_TOGGLE, data) +@bind_hass def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: """Set oscillation on all or specified fan.""" data = { @@ -165,6 +171,7 @@ def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None: hass.services.call(DOMAIN, SERVICE_OSCILLATE, data) +@bind_hass def set_speed(hass, entity_id: str=None, speed: str=None) -> None: """Set speed for all or specified fan.""" data = { @@ -177,6 +184,7 @@ def set_speed(hass, entity_id: str=None, speed: str=None) -> None: hass.services.call(DOMAIN, SERVICE_SET_SPEED, data) +@bind_hass def set_direction(hass, entity_id: str=None, direction: str=None) -> None: """Set direction for all or specified fan.""" data = { diff --git a/homeassistant/components/fan/velbus.py b/homeassistant/components/fan/velbus.py new file mode 100644 index 00000000000..c0d125aa5ab --- /dev/null +++ b/homeassistant/components/fan/velbus.py @@ -0,0 +1,187 @@ +""" +Support for Velbus platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.velbus/ +""" +import asyncio +import logging +import voluptuous as vol + +from homeassistant.components.fan import ( + SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, + PLATFORM_SCHEMA) +from homeassistant.components.velbus import DOMAIN +from homeassistant.const import CONF_NAME, CONF_DEVICES, STATE_OFF +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['velbus'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ + { + vol.Required('module'): cv.positive_int, + vol.Required('channel_low'): cv.positive_int, + vol.Required('channel_medium'): cv.positive_int, + vol.Required('channel_high'): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + } + ]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Fans.""" + velbus = hass.data[DOMAIN] + add_devices(VelbusFan(fan, velbus) for fan in config[CONF_DEVICES]) + + +class VelbusFan(FanEntity): + """Representation of a Velbus Fan.""" + + def __init__(self, fan, velbus): + """Initialize a Velbus light.""" + self._velbus = velbus + self._name = fan[CONF_NAME] + self._module = fan['module'] + self._channel_low = fan['channel_low'] + self._channel_medium = fan['channel_medium'] + self._channel_high = fan['channel_high'] + self._channels = [self._channel_low, self._channel_medium, + self._channel_high] + self._channels_state = [False, False, False] + self._speed = STATE_OFF + + @asyncio.coroutine + def async_added_to_hass(self): + """Add listener for Velbus messages on bus.""" + def _init_velbus(): + """Initialize Velbus on startup.""" + self._velbus.subscribe(self._on_message) + self.get_status() + + yield from self.hass.async_add_job(_init_velbus) + + def _on_message(self, message): + import velbus + if isinstance(message, velbus.RelayStatusMessage) and \ + message.address == self._module and \ + message.channel in self._channels: + if message.channel == self._channel_low: + self._channels_state[0] = message.is_on() + elif message.channel == self._channel_medium: + self._channels_state[1] = message.is_on() + elif message.channel == self._channel_high: + self._channels_state[2] = message.is_on() + self._calculate_speed() + self.schedule_update_ha_state() + + def _calculate_speed(self): + if self._is_off(): + self._speed = STATE_OFF + elif self._is_low(): + self._speed = SPEED_LOW + elif self._is_medium(): + self._speed = SPEED_MEDIUM + elif self._is_high(): + self._speed = SPEED_HIGH + + def _is_off(self): + return self._channels_state[0] is False and \ + self._channels_state[1] is False and \ + self._channels_state[2] is False + + def _is_low(self): + return self._channels_state[0] is True and \ + self._channels_state[1] is False and \ + self._channels_state[2] is False + + def _is_medium(self): + return self._channels_state[0] is True and \ + self._channels_state[1] is True and \ + self._channels_state[2] is False + + def _is_high(self): + return self._channels_state[0] is True and \ + self._channels_state[1] is False and \ + self._channels_state[2] is True + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def speed(self): + """Return the current speed.""" + return self._speed + + @property + def speed_list(self): + """Get the list of available speeds.""" + return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + + def turn_on(self, speed, **kwargs): + """Turn on the entity.""" + if speed is None: + speed = SPEED_MEDIUM + self.set_speed(speed) + + def turn_off(self): + """Turn off the entity.""" + self.set_speed(STATE_OFF) + + def set_speed(self, speed): + """Set the speed of the fan.""" + channels_off = [] + channels_on = [] + if speed == STATE_OFF: + channels_off = self._channels + elif speed == SPEED_LOW: + channels_off = [self._channel_medium, self._channel_high] + channels_on = [self._channel_low] + elif speed == SPEED_MEDIUM: + channels_off = [self._channel_high] + channels_on = [self._channel_low, self._channel_medium] + elif speed == SPEED_HIGH: + channels_off = [self._channel_medium] + channels_on = [self._channel_low, self._channel_high] + for channel in channels_off: + self._relay_off(channel) + for channel in channels_on: + self._relay_on(channel) + self.schedule_update_ha_state() + + def _relay_on(self, channel): + import velbus + message = velbus.SwitchRelayOnMessage() + message.set_defaults(self._module) + message.relay_channels = [channel] + self._velbus.send(message) + + def _relay_off(self, channel): + import velbus + message = velbus.SwitchRelayOffMessage() + message.set_defaults(self._module) + message.relay_channels = [channel] + self._velbus.send(message) + + def get_status(self): + """Retrieve current status.""" + import velbus + message = velbus.ModuleStatusRequestMessage() + message.set_defaults(self._module) + message.channels = self._channels + self._velbus.send(message) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_SET_SPEED diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index c649009b230..3920e606d90 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -9,7 +9,8 @@ import logging from homeassistant.components.fan import (FanEntity, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, - STATE_UNKNOWN) + STATE_UNKNOWN, SUPPORT_SET_SPEED, + SUPPORT_DIRECTION) from homeassistant.helpers.entity import ToggleEntity from homeassistant.components.wink import WinkDevice, DOMAIN @@ -20,6 +21,8 @@ _LOGGER = logging.getLogger(__name__) SPEED_LOWEST = 'lowest' SPEED_AUTO = 'auto' +SUPPORTED_FEATURES = SUPPORT_DIRECTION + SUPPORT_SET_SPEED + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink platform.""" @@ -44,11 +47,11 @@ class WinkFanDevice(WinkDevice, FanEntity): def set_speed(self: ToggleEntity, speed: str) -> None: """Set the speed of the fan.""" - self.wink.set_fan_speed(speed) + self.wink.set_state(True, speed) def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None: """Turn on the fan.""" - self.wink.set_state(True) + self.wink.set_state(True, speed) def turn_off(self: ToggleEntity, **kwargs) -> None: """Turn off the fan.""" @@ -96,3 +99,8 @@ class WinkFanDevice(WinkDevice, FanEntity): if SPEED_HIGH in wink_supported_speeds: supported_speeds.append(SPEED_HIGH) return supported_speeds + + @property + def supported_features(self: ToggleEntity) -> int: + """Flag supported features.""" + return SUPPORTED_FEATURES diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 443ff6f3852..2674eb062a8 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -12,6 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.components import api from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import is_trusted_ip @@ -75,6 +76,7 @@ SERVICE_SET_THEME_SCHEMA = vol.Schema({ }) +@bind_hass def register_built_in_panel(hass, component_name, sidebar_title=None, sidebar_icon=None, url_path=None, config=None): """Register a built-in panel.""" @@ -96,6 +98,7 @@ def register_built_in_panel(hass, component_name, sidebar_title=None, sidebar_icon, url_path, url, config) +@bind_hass def register_panel(hass, component_name, path, md5=None, sidebar_title=None, sidebar_icon=None, url_path=None, url=None, config=None): """Register a panel for the frontend. diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py index 67c8bbac817..469452f7e49 100644 --- a/homeassistant/components/frontend/version.py +++ b/homeassistant/components/frontend/version.py @@ -3,21 +3,22 @@ FINGERPRINTS = { "compatibility.js": "8e4c44b5f4288cc48ec1ba94a9bec812", "core.js": "d4a7cb8c80c62b536764e0e81385f6aa", - "frontend.html": "a7d4cb8260e8094342b5bd8c36c4bf5b", + "frontend.html": "7d599996578579600f1000d6d25e649d", "mdi.html": "e91f61a039ed0a9936e7ee5360da3870", "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a", - "panels/ha-panel-automation.html": "72a5c1856cece8d9246328e84185ab0b", - "panels/ha-panel-config.html": "76853de505d173e82249bf605eb73505", - "panels/ha-panel-dev-event.html": "4886c821235492b1b92739b580d21c61", + "panels/ha-panel-automation.html": "1982116c49ad26ee8d89295edc797084", + "panels/ha-panel-config.html": "fafeac72f83dd6cc42218f8978f6a7af", + "panels/ha-panel-dev-event.html": "77784d5f0c73fcc3b29b6cc050bdf324", "panels/ha-panel-dev-info.html": "24e888ec7a8acd0c395b34396e9001bc", - "panels/ha-panel-dev-service.html": "ac2c50e486927dc4443e93d79f08c06e", - "panels/ha-panel-dev-state.html": "8f1a27c04db6329d31cfcc7d0d6a0869", - "panels/ha-panel-dev-template.html": "82cd543177c417e5c6612e07df851e6b", + "panels/ha-panel-dev-service.html": "86a42a17f4894478b6b77bc636beafd0", + "panels/ha-panel-dev-state.html": "31ef6ffe3347cdda5bb0cbbc54b62cde", + "panels/ha-panel-dev-template.html": "d1d76e20fe9622cddee33e67318abde8", "panels/ha-panel-hassio.html": "262d31efd9add719e0325da5cf79a096", "panels/ha-panel-history.html": "35177e2046c9a4191c8f51f8160255ce", - "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab", + "panels/ha-panel-iframe.html": "238189f21e670b6dcfac937e5ebd7d3b", "panels/ha-panel-kiosk.html": "2ac2df41bd447600692a0054892fc094", "panels/ha-panel-logbook.html": "7c45bd41c146ec38b9938b8a5188bb0d", - "panels/ha-panel-map.html": "d3dae1400ec4e4cd7681d2aa79131d55", - "panels/ha-panel-zwave.html": "2ea2223339d1d2faff478751c2927d11" + "panels/ha-panel-map.html": "50501cd53eb4304e9e46eb719aa894b7", + "panels/ha-panel-shopping-list.html": "1d7126efc9ff9a102df7465d803a11d1", + "panels/ha-panel-zwave.html": "422f95f820f8b6b231265351ffcf4dd1" } diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html index 41e9975e8b3..7dd7b17c987 100644 --- a/homeassistant/components/frontend/www_static/frontend.html +++ b/homeassistant/components/frontend/www_static/frontend.html @@ -1,5 +1,5 @@ - \ No newline at end of file +;if(t)for(var n in e){var r=t[n];if(r)for(var s,i=0,o=r.length;i1){for(var a=0;a+~])"},resolveCss:Polymer.ResolveUrl.resolveCss,parser:Polymer.CssParse,ruleTypes:Polymer.CssParse.types}}(),Polymer.StyleTransformer=function(){var e=Polymer.StyleUtil,t=Polymer.Settings,n={dom:function(e,t,n,r){this._transformDom(e,t||"",n,r)},_transformDom:function(e,t,n,r){e.setAttribute&&this.element(e,t,n,r);for(var s=Polymer.dom(e).childNodes,i=0;i *"),r&&r(e)}}for(var u,f=0,p=i.length;f *"),e=e.replace(P,l+" $1"),e=e.replace(o,function(e,i,o){if(r)o=o.replace(_," ");else{var l=a._transformCompoundSelector(o,i,t,n);r=r||l.stop,s=s||l.hostContext,i=l.combinator,o=l.value}return i+o}),s&&(e=e.replace(f,function(e,t,r,s){return t+r+" "+n+s+i+" "+t+n+r+s})),e},_transformCompoundSelector:function(e,t,n,r){var s=e.search(_),i=!1;e.indexOf(u)>=0?i=!0:e.indexOf(l)>=0?e=this._transformHostSelector(e,r):0!==s&&(e=n?this._transformSimpleSelector(e,n):e),e.indexOf(p)>=0&&(t="");var o;return s>=0&&(e=e.replace(_," "),o=!0),{value:e,combinator:t,stop:o,hostContext:i}},_transformSimpleSelector:function(e,t){var n=e.split(v);return n[0]+=t,n.join(v)},_transformHostSelector:function(e,t){var n=e.match(h),r=n&&n[2].trim()||"";if(r){if(r[0].match(a))return e.replace(h,function(e,n,r){return t+r});return r.split(a)[0]===t?r:S}return e.replace(l,t)},documentRule:function(e){e.selector=e.parsedSelector,this.normalizeRootSelector(e),t.useNativeShadow||this._transformRule(e,this._transformDocumentSelector)},normalizeRootSelector:function(e){e.selector=e.selector.replace(c,"html");var t=e.selector.split(i);t=t.filter(function(e){return!e.match(C)}),e.selector=t.join(i)},_transformDocumentSelector:function(e){return e.match(_)?this._transformComplexSelector(e,s):this._transformSimpleSelector(e.trim(),s)},_slottedToContent:function(e){return e.replace(E,p+"> $1")},SCOPE_NAME:"style-scope"},r=n.SCOPE_NAME,s=":not(["+r+"]):not(."+r+")",i=",",o=/(^|[\s>+~]+)((?:\[.+?\]|[^\s>+~=\[])+)/g,a=/[[.:#*]/,l=":host",c=":root",h=/(:host)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/,u=":host-context",f=/(.*)(?::host-context)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))(.*)/,p="::content",_=/::content|::shadow|\/deep\//,d=".",m="["+r+"~=",y="]",v=":",g="class",P=new RegExp("^("+p+")"),S="should_not_match",E=/(?:::slotted)(?:\(((?:\([^)(]*\)|[^)(]*)+?)\))/g,C=/:host(?:\s*>\s*\*)?/;return n}(),Polymer.StyleExtends=function(){var e=Polymer.StyleUtil;return{hasExtends:function(e){return Boolean(e.match(this.rx.EXTEND))},transform:function(t){var n=e.rulesForStyle(t),r=this;return e.forEachRule(n,function(e){if(r._mapRuleOntoParent(e),e.parent)for(var t;t=r.rx.EXTEND.exec(e.cssText);){var n=t[1],s=r._findExtendor(n,e);s&&r._extendRule(e,s)}e.cssText=e.cssText.replace(r.rx.EXTEND,"")}),e.toCssText(n,function(e){e.selector.match(r.rx.STRIP)&&(e.cssText="")},!0)},_mapRuleOntoParent:function(e){if(e.parent){for(var t,n=e.parent.map||(e.parent.map={}),r=e.selector.split(","),s=0;s1&&(t=i[0].trim(),r=n(t,i.slice(1).join(":")),a[t]=r));return a}function s(e){var t=m.__currentElementProto,n=t&&t.is;for(var r in e.dependants)r!==n&&(e.dependants[r].__applyShimInvalid=!0)}function i(n,i,o,a){if(o&&c.processVariableAndFallback(o,function(e,n){n&&t(n)&&(a="@apply "+n+";")}),!a)return n;var h=l(a),u=n.slice(0,n.indexOf("--")),f=r(h),p=f,d=t(i),m=d&&d.properties;m?(p=Object.create(m),p=Polymer.Base.mixin(p,f)):e(i,p);var y,v,g=[],P=!1;for(y in p)v=f[y],void 0===v&&(v="initial"),!m||y in m||(P=!0),g.push(i+_+y+": "+v);return P&&s(d),d&&(d.properties=p),o&&(u=n+";"+u),u+g.join("; ")+";"}function o(e,t,n){return"var("+t+",var("+n+"))"}function a(n,r){n=n.replace(f,"");var s=[],i=t(n);if(i||(e(n,{}),i=t(n)),i){var o=m.__currentElementProto;o&&(i.dependants[o.is]=o);var a,l,c;for(a in i.properties)c=r&&r[a],l=[a,": var(",n,_,a],c&&l.push(",",c),l.push(")"),s.push(l.join(""))}return s.join("; ")}function l(e){for(var t;t=h.exec(e);){var n=t[0],s=t[1],i=t.index,o=i+n.indexOf("@apply"),l=i+n.length,c=e.slice(0,o),u=e.slice(l),f=r(c),p=a(s,f);e=[c,p,u].join(""),h.lastIndex=i+p.length}return e}var c=Polymer.StyleUtil,h=c.rx.MIXIN_MATCH,u=c.rx.VAR_ASSIGN,f=/;\s*/m,p=/^\s*(initial)|(inherit)\s*$/,_="_-_",d={},m={_measureElement:null,_map:d,_separator:_,transform:function(e,t){this.__currentElementProto=t,c.forRulesInStyles(e,this._boundFindDefinitions),c.forRulesInStyles(e,this._boundFindApplications),t&&(t.__applyShimInvalid=!1),this.__currentElementProto=null},_findDefinitions:function(e){var t=e.parsedCssText;t=t.replace(/var\(\s*(--[^,]*),\s*(--[^)]*)\)/g,o),t=t.replace(u,i),e.cssText=t,":root"===e.selector&&(e.selector=":host > *")},_findApplications:function(e){e.cssText=l(e.cssText)},transformRule:function(e){this._findDefinitions(e),this._findApplications(e)},_getInitialValueForProperty:function(e){return this._measureElement||(this._measureElement=document.createElement("meta"),this._measureElement.style.all="initial",document.head.appendChild(this._measureElement)),window.getComputedStyle(this._measureElement).getPropertyValue(e)}};return m._boundTransformRule=m.transformRule.bind(m),m._boundFindDefinitions=m._findDefinitions.bind(m),m._boundFindApplications=m._findApplications.bind(m),m}(),function(){var e=Polymer.Base._prepElement,t=Polymer.Settings.useNativeShadow,n=Polymer.StyleUtil,r=Polymer.StyleTransformer,s=Polymer.StyleExtends,i=Polymer.ApplyShim,o=Polymer.Settings;Polymer.Base._addFeature({_prepElement:function(t){this._encapsulateStyle&&"shady"!==this.__cssBuild&&r.element(t,this.is,this._scopeCssViaAttr),e.call(this,t)},_prepStyles:function(){void 0===this._encapsulateStyle&&(this._encapsulateStyle=!t),t||(this._scopeStyle=n.applyStylePlaceHolder(this.is)),this.__cssBuild=n.cssBuildTypeForModule(this.is)},_prepShimStyles:function(){if(this._template){var e=n.isTargetedBuild(this.__cssBuild);if(o.useNativeCSSProperties&&"shadow"===this.__cssBuild&&e)return void(o.preserveStyleIncludes&&n.styleIncludesToTemplate(this._template));this._styles=this._styles||this._collectStyles(),o.useNativeCSSProperties&&!this.__cssBuild&&i.transform(this._styles,this);var s=o.useNativeCSSProperties&&e?this._styles.length&&this._styles[0].textContent.trim():r.elementStyles(this);this._prepStyleProperties(),!this._needsStyleProperties()&&s&&n.applyCss(s,this.is,t?this._template.content:null,this._scopeStyle)}else this._styles=[]},_collectStyles:function(){var e=[],t="",r=this.styleModules;if(r)for(var i,o=0,a=r.length;o=0)e=this.valueForProperties(e,t);else{var r=this,s=function(e,n,s,i){var o=r.valueForProperty(t[n],t);return o&&"initial"!==o?"apply-shim-inherit"===o&&(o="inherit"):o=r.valueForProperty(t[s]||s,t)||s,e+(o||"")+i};e=n.processVariableAndFallback(e,s)}return e&&e.trim()||""},valueForProperties:function(e,t){for(var n,r,s=e.split(";"),i=0;i\s*\*/,_checkRoot:function(e,t){return Boolean(t.match(this._rootSelector))||"html"===e&&t.indexOf("html")>-1},whenHostOrRootRule:function(e,t,n,s){if(t.propertyInfo||self.decorateRule(t),t.propertyInfo.properties){var o=e.is?r._calcHostScope(e.is,e.extends):"html",a=t.parsedSelector,l=this._checkRoot(o,a),c=!l&&0===a.indexOf(":host");if("shady"===(e.__cssBuild||n.__cssBuild)&&(l=a===o+" > *."+o||a.indexOf("html")>-1,c=!l&&0===a.indexOf(o)),l||c){var h=o;c&&(i.useNativeShadow&&!t.transformedSelector&&(t.transformedSelector=r._transformRuleCss(t,r._transformComplexSelector,e.is,o)),h=t.transformedSelector||t.parsedSelector),l&&"html"===o&&(h=t.transformedSelector||t.parsedSelector),s({selector:h,isHost:c,isRoot:l})}}},hostAndRootPropertiesForScope:function(e){var r={},s={},i=this;return n.forActiveRulesInStyles(e._styles,function(n,o){i.whenHostOrRootRule(e,n,o,function(o){var a=e._element||e;t.call(a,o.selector)&&(o.isHost?i.collectProperties(n,r):i.collectProperties(n,s))})}),{rootProps:s,hostProps:r}},transformStyles:function(e,t,n){var s=this,o=r._calcHostScope(e.is,e.extends),a=e.extends?"\\"+o.slice(0,-1)+"\\]":o,l=new RegExp(this.rx.HOST_PREFIX+a+this.rx.HOST_SUFFIX),c=this._elementKeyframeTransforms(e,n);return r.elementStyles(e,function(r){s.applyProperties(r,t),i.useNativeShadow||Polymer.StyleUtil.isKeyframesSelector(r)||!r.cssText||(s.applyKeyframeTransforms(r,c),s._scopeSelector(r,l,o,e._scopeCssViaAttr,n))})},_elementKeyframeTransforms:function(e,t){var n=e._styles._keyframes,r={};if(!i.useNativeShadow&&n)for(var s=0,o=n[s];s-1&&(o.textContent=a),n.applyStyle(o,null,e._scopeStyle)):a&&(o=n.applyCss(a,r,null,e._scopeStyle)),o&&(o._useCount=o._useCount||0,e._customStyle!=o&&o._useCount++,e._customStyle=o),o},mixinCustomStyle:function(e,t){var n;for(var r in t)((n=t[r])||0===n)&&(e[r]=n)},updateNativeStyleProperties:function(e,t){var n=e.__customStyleProperties;if(n)for(var r=0;rthis.MAX&&s.shift()},retrieve:function(e,t,n){var r=this.cache[e];if(r)for(var s,i=r.length-1;i>=0;i--)if(s=r[i],n===s.styles&&this._objectsEqual(t,s.keyValues))return s},clear:function(){this.cache={}},_objectsEqual:function(e,t){var n,r;for(var s in e)if(n=e[s],r=t[s],!("object"==typeof n&&n?this._objectsStrictlyEqual(n,r):n===r))return!1;return!Array.isArray(e)||e.length===t.length},_objectsStrictlyEqual:function(e,t){return this._objectsEqual(e,t)&&this._objectsEqual(t,e)}}}(),Polymer.StyleDefaults=function(){var e=Polymer.StyleProperties,t=Polymer.StyleCache,n=Polymer.Settings.useNativeCSSProperties;return{_styles:[],_properties:null,customStyle:{},_styleCache:new t,_element:Polymer.DomApi.wrap(document.documentElement),addStyle:function(e){this._styles.push(e),this._properties=null},get _styleProperties(){return this._properties||(e.decorateStyles(this._styles,this),this._styles._scopeStyleProperties=null,this._properties=e.hostAndRootPropertiesForScope(this).rootProps,e.mixinCustomStyle(this._properties,this.customStyle),e.reify(this._properties)),this._properties},hasStyleProperties:function(){return Boolean(this._properties)},_needsStyleProperties:function(){},_computeStyleProperties:function(){return this._styleProperties},updateStyles:function(t){this._properties=null,t&&Polymer.Base.mixin(this.customStyle,t),this._styleCache.clear() +;for(var r,s=0;s0&&l.push(t);return[{removed:a,added:l}]}},Polymer.Collection.get=function(e){return Polymer._collections.get(e)||new Polymer.Collection(e)},Polymer.Collection.applySplices=function(e,t){var n=Polymer._collections.get(e);return n?n._applySplices(t):null},Polymer({is:"dom-repeat",extends:"template",_template:null,properties:{items:{type:Array},as:{type:String,value:"item"},indexAs:{type:String,value:"index"},sort:{type:Function,observer:"_sortChanged"},filter:{type:Function,observer:"_filterChanged"},observe:{type:String,observer:"_observeChanged"},delay:Number,renderedItemCount:{type:Number,notify:!Polymer.Settings.suppressTemplateNotifications,readOnly:!0},initialCount:{type:Number,observer:"_initializeChunking"},targetFramerate:{type:Number,value:20},notifyDomChange:{type:Boolean},_targetFrameTime:{type:Number,computed:"_computeFrameTime(targetFramerate)"}},behaviors:[Polymer.Templatizer],observers:["_itemsChanged(items.*)"],created:function(){this._instances=[],this._pool=[],this._limit=1/0;var e=this;this._boundRenderChunk=function(){e._renderChunk()}},detached:function(){this.__isDetached=!0;for(var e=0;e=0;t--){var n=this._instances[t];n.isPlaceholder&&t=this._limit&&(n=this._downgradeInstance(t,n.__key__)),e[n.__key__]=t,n.isPlaceholder||n.__setProperty(this.indexAs,t,!0)}this._pool.length=0,this._setRenderedItemCount(this._instances.length),Polymer.Settings.suppressTemplateNotifications&&!this.notifyDomChange||this.fire("dom-change"),this._tryRenderChunk()},_applyFullRefresh:function(){var e,t=this.collection;if(this._sortFn)e=t?t.getKeys():[];else{e=[];var n=this.items;if(n)for(var r=0;r=r;a--)this._detachAndRemoveInstance(a)},_numericSort:function(e,t){return e-t},_applySplicesUserSort:function(e){for(var t,n,r=this.collection,s={},i=0;i=0;i--){var c=a[i];void 0!==c&&this._detachAndRemoveInstance(c)}var h=this;if(l.length){this._filterFn&&(l=l.filter(function(e){return h._filterFn(r.getItem(e))})),l.sort(function(e,t){return h._sortFn(r.getItem(e),r.getItem(t))});var u=0;for(i=0;i>1,a=this._instances[o].__key__,l=this._sortFn(n.getItem(a),r);if(l<0)e=o+1;else{if(!(l>0)){i=o;break}s=o-1}}return i<0&&(i=s+1),this._insertPlaceholder(i,t),i},_applySplicesArrayOrder:function(e){for(var t,n=0;n=0?(e=this.as+"."+e.substring(n+1),i._notifyPath(e,t,!0)):i.__setProperty(this.as,t,!0))}},itemForElement:function(e){var t=this.modelForElement(e);return t&&t[this.as]},keyForElement:function(e){var t=this.modelForElement(e);return t&&t.__key__},indexForElement:function(e){var t=this.modelForElement(e);return t&&t[this.indexAs]}}),Polymer({is:"array-selector",_template:null,properties:{items:{type:Array,observer:"clearSelection"},multi:{type:Boolean,value:!1,observer:"clearSelection"},selected:{type:Object,notify:!0},selectedItem:{type:Object,notify:!0},toggle:{type:Boolean,value:!1}},clearSelection:function(){if(Array.isArray(this.selected))for(var e=0;e \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/frontend.html.gz b/homeassistant/components/frontend/www_static/frontend.html.gz index c04830c01a9..d41c459ae9b 100644 Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and b/homeassistant/components/frontend/www_static/frontend.html.gz differ diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer index 7c079dc01f0..7f6aeaf2a2e 160000 --- a/homeassistant/components/frontend/www_static/home-assistant-polymer +++ b/homeassistant/components/frontend/www_static/home-assistant-polymer @@ -1 +1 @@ -Subproject commit 7c079dc01f03d7599762df1ac8c6eab8e4b90004 +Subproject commit 7f6aeaf2a2e8a91f59030300fbf47f0b8e3952f1 diff --git a/homeassistant/components/frontend/www_static/images/config_wink.png b/homeassistant/components/frontend/www_static/images/config_wink.png new file mode 100644 index 00000000000..6b91f8cb58e Binary files /dev/null and b/homeassistant/components/frontend/www_static/images/config_wink.png differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html index 4da09363373..376d9b8a378 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.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-automation.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz index 7d3cf0d8152..051b7e0552a 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-automation.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html index 5e6cf7b6483..104b090ed61 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-config.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-config.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-config.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-config.html.gz index 8c4cf065663..8e8ba12d1b4 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-event.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html index 6f501f7db26..236e0fb286a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.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-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz index 1a8faa4f3e6..2f78956badf 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html index ed1ff9fe85d..aeab2aaf465 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.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-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz index c76c9bc6de1..c479a26990f 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html index 6f03db94edf..75d9dfa9d6e 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.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-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz index 742f5eef9ee..e28e4ae8f30 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.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 bb27f9a2d7d..c1be1953639 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 +1 @@ - \ No newline at end of file + \ 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 9c3063c0d6c..d5877c44dc0 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-iframe.html b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html index ff2cdbfe4b4..7e7c2afd61a 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.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-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz index ef8727a2ea3..afa1e40bf9d 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.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 500f812a738..33e972c2349 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html @@ -2,4 +2,4 @@ pt(this._mapPane,"leaflet-pan-anim");var e=this._getMapPanePos().subtract(t).round();this._panAnim.run(this._mapPane,e,i.duration||.25,i.easeLinearity)}else this._rawPanBy(t),this.fire("move").fire("moveend");return this},flyTo:function(t,i,e){function n(t){var i=t?-1:1,e=t?g:m,n=g*g-m*m+i*x*x*v*v,o=2*e*x*v,s=n/o,r=Math.sqrt(s*s+1)-s;return r<1e-9?-18:Math.log(r)}function o(t){return(Math.exp(t)-Math.exp(-t))/2}function s(t){return(Math.exp(t)+Math.exp(-t))/2}function r(t){return o(t)/s(t)}function a(t){return m*(s(w)/s(w+y*t))}function h(t){return m*(s(w)*r(w+y*t)-o(w))/x}function u(t){return 1-Math.pow(1-t,1.5)}function l(){var e=(Date.now()-L)/b,n=u(e)*P;e<=1?(this._flyToFrame=f(l,this),this._move(this.unproject(c.add(_.subtract(c).multiplyBy(h(n)/v)),p),this.getScaleZoom(m/a(n),p),{flyTo:!0})):this._move(t,i)._moveEnd(!0)}if(e=e||{},!1===e.animate||!Ki)return this.setView(t,i,e);this._stop();var c=this.project(this.getCenter()),_=this.project(t),d=this.getSize(),p=this._zoom;t=C(t),i=void 0===i?p:i;var m=Math.max(d.x,d.y),g=m*this.getZoomScale(p,i),v=_.distanceTo(c)||1,y=1.42,x=y*y,w=n(0),L=Date.now(),P=(n(1)-w)/y,b=e.duration?1e3*e.duration:1e3*P*.8;return this._moveStart(!0),l.call(this),this},flyToBounds:function(t,i){var e=this._getBoundsCenterZoom(t,i);return this.flyTo(e.center,e.zoom,i)},setMaxBounds:function(t){return t=z(t),t.isValid()?(this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this.options.maxBounds=t,this._loaded&&this._panInsideMaxBounds(),this.on("moveend",this._panInsideMaxBounds)):(this.options.maxBounds=null,this.off("moveend",this._panInsideMaxBounds))},setMinZoom:function(t){return this.options.minZoom=t,this._loaded&&this.getZoom()this.options.maxZoom?this.setZoom(t):this},panInsideBounds:function(t,i){this._enforcingBounds=!0;var e=this.getCenter(),n=this._limitCenter(e,this._zoom,z(t));return e.equals(n)||this.panTo(n,i),this._enforcingBounds=!1,this},invalidateSize:function(t){if(!this._loaded)return this;t=i({animate:!1,pan:!0},!0===t?{animate:!0}:t);var n=this.getSize();this._sizeChanged=!0,this._lastCenter=null;var o=this.getSize(),s=n.divideBy(2).round(),r=o.divideBy(2).round(),a=s.subtract(r);return a.x||a.y?(t.animate&&t.pan?this.panBy(a):(t.pan&&this._rawPanBy(a),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(e(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:n,newSize:o})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){if(t=this._locateOptions=i({timeout:1e4,watch:!1},t),!("geolocation"in navigator))return this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this;var n=e(this._handleGeolocationResponse,this),o=e(this._handleGeolocationError,this);return t.watch?this._locationWatchId=navigator.geolocation.watchPosition(n,o,t):navigator.geolocation.getCurrentPosition(n,o,t),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var i=t.code,e=t.message||(1===i?"permission denied":2===i?"position unavailable":"timeout");this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:i,message:"Geolocation error: "+e+"."})},_handleGeolocationResponse:function(t){var i=t.coords.latitude,e=t.coords.longitude,n=new M(i,e),o=n.toBounds(t.coords.accuracy),s=this._locateOptions;if(s.setView){var r=this.getBoundsZoom(o);this.setView(n,s.maxZoom?Math.min(r,s.maxZoom):r)}var a={latlng:n,bounds:o,timestamp:t.timestamp};for(var h in t.coords)"number"==typeof t.coords[h]&&(a[h]=t.coords[h]);this.fire("locationfound",a)},addHandler:function(t,i){if(!i)return this;var e=this[t]=new i(this);return this._handlers.push(e),this.options[t]&&e.enable(),this},remove:function(){if(this._initEvents(!0),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}ut(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._clearHandlers(),this._loaded&&this.fire("unload");var t;for(t in this._layers)this._layers[t].remove();for(t in this._panes)ut(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,i){var e="leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),n=ht("div",e,i||this._mapPane);return t&&(this._panes[t]=n),n},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter:this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new T(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,i,e){t=z(t),e=w(e||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),a=t.getSouthEast(),h=this.getSize().subtract(e),u=b(this.project(a,n),this.project(r,n)).getSize(),l=Ki?this.options.zoomSnap:1,c=h.x/u.x,_=h.y/u.y,d=i?Math.max(c,_):Math.min(c,_);return n=this.getScaleZoom(d,n),l&&(n=Math.round(n/(l/100))*(l/100),n=i?Math.ceil(n/l)*l:Math.floor(n/l)*l),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new x(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,i){var e=this._getTopLeftPoint(t,i);return new P(e,e.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,i){var e=this.options.crs;return i=void 0===i?this._zoom:i,e.scale(t)/e.scale(i)},getScaleZoom:function(t,i){var e=this.options.crs;i=void 0===i?this._zoom:i;var n=e.zoom(t*e.scale(i));return isNaN(n)?1/0:n},project:function(t,i){return i=void 0===i?this._zoom:i,this.options.crs.latLngToPoint(C(t),i)},unproject:function(t,i){return i=void 0===i?this._zoom:i,this.options.crs.pointToLatLng(w(t),i)},layerPointToLatLng:function(t){var i=w(t).add(this.getPixelOrigin());return this.unproject(i)},latLngToLayerPoint:function(t){return this.project(C(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(C(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(z(t))},distance:function(t,i){return this.options.crs.distance(C(t),C(i))},containerPointToLayerPoint:function(t){return w(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return w(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){var i=this.containerPointToLayerPoint(w(t));return this.layerPointToLatLng(i)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(C(t)))},mouseEventToContainerPoint:function(t){return tt(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){var i=this._container=rt(t);if(!i)throw new Error("Map container not found.");if(i._leaflet_id)throw new Error("Map container is already initialized.");V(i,"scroll",this._onScroll,this),this._containerId=n(i)},_initLayout:function(){var t=this._container;this._fadeAnimated=this.options.fadeAnimation&&Ki,pt(t,"leaflet-container"+(te?" leaflet-touch":"")+(ne?" leaflet-retina":"")+(Bi?" leaflet-oldie":"")+(Wi?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":""));var i=at(t,"position");"absolute"!==i&&"relative"!==i&&"fixed"!==i&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Lt(this._mapPane,new x(0,0)),this.createPane("tilePane"),this.createPane("shadowPane"),this.createPane("overlayPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(pt(t.markerPane,"leaflet-zoom-hide"),pt(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,i){Lt(this._mapPane,new x(0,0));var e=!this._loaded;this._loaded=!0,i=this._limitZoom(i),this.fire("viewprereset");var n=this._zoom!==i;this._moveStart(n)._move(t,i)._moveEnd(n),this.fire("viewreset"),e&&this.fire("load")},_moveStart:function(t){return t&&this.fire("zoomstart"),this.fire("movestart")},_move:function(t,i,e){void 0===i&&(i=this._zoom);var n=this._zoom!==i;return this._zoom=i,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),(n||e&&e.pinch)&&this.fire("zoom",e),this.fire("move",e)},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return g(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Lt(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={},this._targets[n(this._container)]=this;var i=t?G:V;i(this._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress",this._handleDOMEvent,this),this.options.trackResize&&i(window,"resize",this._onResize,this),Ki&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){g(this._resizeRequest),this._resizeRequest=f(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,i){for(var e,o=[],s="mouseout"===i||"mouseover"===i,r=t.target||t.srcElement,a=!1;r;){if((e=this._targets[n(r)])&&("click"===i||"preclick"===i)&&!t._simulated&&this._draggableMoved(e)){a=!0;break}if(e&&e.listens(i,!0)){if(s&&!ot(r,t))break;if(o.push(e),s)break}if(r===this._container)break;r=r.parentNode}return o.length||a||s||!ot(r,t)||(o=[this]),o},_handleDOMEvent:function(t){if(this._loaded&&!nt(t)){var i=t.type;"mousedown"!==i&&"keypress"!==i||zt(t.target||t.srcElement),this._fireDOMEvent(t,i)}},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,n){if("click"===t.type){var o=i({},t);o.type="preclick",this._fireDOMEvent(o,o.type,n)}if(!t._stopped&&(n=(n||[]).concat(this._findEventTargets(t,e)),n.length)){var s=n[0];"contextmenu"===e&&s.listens(e,!0)&&$(t);var r={originalEvent:t};if("keypress"!==t.type){var a=s.options&&"icon"in s.options;r.containerPoint=a?this.latLngToContainerPoint(s.getLatLng()):this.mouseEventToContainerPoint(t),r.layerPoint=this.containerPointToLayerPoint(r.containerPoint),r.latlng=a?s.getLatLng():this.layerPointToLatLng(r.layerPoint)}for(var h=0;h0?Math.round(t-i)/2:Math.max(0,Math.ceil(t))-Math.max(0,Math.floor(i))},_limitZoom:function(t){var i=this.getMinZoom(),e=this.getMaxZoom(),n=Ki?this.options.zoomSnap:1;return n&&(t=Math.round(t/n)*n),Math.max(i,Math.min(e,t))},_onPanTransitionStep:function(){this.fire("move")},_onPanTransitionEnd:function(){mt(this._mapPane,"leaflet-pan-anim"),this.fire("moveend")},_tryAnimatedPan:function(t,i){var e=this._getCenterOffset(t)._floor();return!(!0!==(i&&i.animate)&&!this.getSize().contains(e)||(this.panBy(e,i),0))},_createAnimProxy:function(){var t=this._proxy=ht("div","leaflet-proxy leaflet-zoom-animated");this._panes.mapPane.appendChild(t),this.on("zoomanim",function(t){var i=Pe,e=this._proxy.style[i];wt(this._proxy,this.project(t.center,t.zoom),this.getZoomScale(t.zoom,1)),e===this._proxy.style[i]&&this._animatingZoom&&this._onZoomTransitionEnd()},this),this.on("load moveend",function(){var t=this.getCenter(),i=this.getZoom();wt(this._proxy,this.project(t,i),this.getZoomScale(i,1))},this),this._on("unload",this._destroyAnimProxy,this)},_destroyAnimProxy:function(){ut(this._proxy),delete this._proxy},_catchTransitionEnd:function(t){this._animatingZoom&&t.propertyName.indexOf("transform")>=0&&this._onZoomTransitionEnd()},_nothingToAnimate:function(){return!this._container.getElementsByClassName("leaflet-zoom-animated").length},_tryAnimatedZoom:function(t,i,e){if(this._animatingZoom)return!0;if(e=e||{},!this._zoomAnimated||!1===e.animate||this._nothingToAnimate()||Math.abs(i-this._zoom)>this.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(i),o=this._getCenterOffset(t)._divideBy(1-1/n);return!(!0!==e.animate&&!this.getSize().contains(o)||(f(function(){this._moveStart(!0)._animateZoom(t,i,!0)},this),0))},_animateZoom:function(t,i,n,o){n&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=i,pt(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:i,noUpdate:o}),setTimeout(e(this._onZoomTransitionEnd,this),250)},_onZoomTransitionEnd:function(){this._animatingZoom&&(mt(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom),f(function(){this._moveEnd(!0)},this))}}),ke=v.extend({options:{position:"topright"},initialize:function(t){l(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var i=this._map;return i&&i.removeControl(this),this.options.position=t,i&&i.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var i=this._container=this.onAdd(t),e=this.getPosition(),n=t._controlCorners[e];return pt(i,"leaflet-control"),-1!==e.indexOf("bottom")?n.insertBefore(i,n.firstChild):n.appendChild(i),this},remove:function(){return this._map?(ut(this._container),this.onRemove&&this.onRemove(this._map),this._map=null,this):this},_refocusOnMap:function(t){this._map&&t&&t.screenX>0&&t.screenY>0&&this._map.getContainer().focus()}}),Be=function(t){return new ke(t)};Se.include({addControl:function(t){return t.addTo(this),this},removeControl:function(t){return t.remove(),this},_initControlPos:function(){function t(t,o){var s=e+t+" "+e+o;i[t+o]=ht("div",s,n)}var i=this._controlCorners={},e="leaflet-",n=this._controlContainer=ht("div",e+"control-container",this._container);t("top","left"),t("top","right"),t("bottom","left"),t("bottom","right")},_clearControlPos:function(){for(var t in this._controlCorners)ut(this._controlCorners[t]);ut(this._controlContainer),delete this._controlCorners,delete this._controlContainer}});var Ie=ke.extend({options:{collapsed:!0,position:"topright",autoZIndex:!0,hideSingleBase:!1,sortLayers:!1,sortFunction:function(t,i,e,n){return e1,this._baseLayersList.style.display=t?"":"none"),this._separator.style.display=i&&t?"":"none",this},_onLayerChange:function(t){this._handlingClick||this._update();var i=this._getLayer(n(t.target)),e=i.overlay?"add"===t.type?"overlayadd":"overlayremove":"add"===t.type?"baselayerchange":null;e&&this._map.fire(e,i)},_createRadioElement:function(t,i){var e='",n=document.createElement("div");return n.innerHTML=e,n.firstChild},_addItem:function(t){var i,e=document.createElement("label"),o=this._map.hasLayer(t.layer);t.overlay?(i=document.createElement("input"),i.type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=o):i=this._createRadioElement("leaflet-base-layers",o),this._layerControlInputs.push(i),i.layerId=n(t.layer),V(i,"click",this._onInputClick,this);var s=document.createElement("span");s.innerHTML=" "+t.name;var r=document.createElement("div");return e.appendChild(r),r.appendChild(i),r.appendChild(s),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(e),this._checkDisabledLayers(),e},_onInputClick:function(){var t,i,e,n=this._layerControlInputs,o=[],s=[];this._handlingClick=!0;for(var r=n.length-1;r>=0;r--)t=n[r],i=this._getLayer(t.layerId).layer,e=this._map.hasLayer(i),t.checked&&!e?o.push(i):!t.checked&&e&&s.push(i);for(r=0;r=0;o--)t=e[o],i=this._getLayer(t.layerId).layer,t.disabled=void 0!==i.options.minZoom&&ni.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),Ae=function(t,i,e){return new Ie(t,i,e)},Oe=ke.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"−",zoomOutTitle:"Zoom out"},onAdd:function(t){var i="leaflet-control-zoom",e=ht("div",i+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,i+"-in",e,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,i+"-out",e,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),e},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,i,e,n,o){var s=ht("a",e,n);return s.innerHTML=t,s.href="#",s.title=i,s.setAttribute("role","button"),s.setAttribute("aria-label",i),J(s),V(s,"click",Q),V(s,"click",o,this),V(s,"click",this._refocusOnMap,this),s},_updateDisabled:function(){var t=this._map,i="leaflet-disabled";mt(this._zoomInButton,i),mt(this._zoomOutButton,i),(this._disabled||t._zoom===t.getMinZoom())&&pt(this._zoomOutButton,i),(this._disabled||t._zoom===t.getMaxZoom())&&pt(this._zoomInButton,i)}});Se.mergeOptions({zoomControl:!0}),Se.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new Oe,this.addControl(this.zoomControl))});var Re=function(t){return new Oe(t)},De=ke.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var i="leaflet-control-scale",e=ht("div",i),n=this.options;return this._addScales(n,i+"-line",e),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),e},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,i,e){t.metric&&(this._mScale=ht("div",i,e)),t.imperial&&(this._iScale=ht("div",i,e))},_update:function(){var t=this._map,i=t.getSize().y/2,e=t.distance(t.containerPointToLatLng([0,i]),t.containerPointToLatLng([this.options.maxWidth,i]));this._updateScales(e)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var i=this._getRoundNum(t),e=i<1e3?i+" m":i/1e3+" km";this._updateScale(this._mScale,e,i/t)},_updateImperial:function(t){var i,e,n,o=3.2808399*t;o>5280?(i=o/5280,e=this._getRoundNum(i),this._updateScale(this._iScale,e+" mi",e/i)):(n=this._getRoundNum(o),this._updateScale(this._iScale,n+" ft",n/o))},_updateScale:function(t,i,e){t.style.width=Math.round(this.options.maxWidth*e)+"px",t.innerHTML=i},_getRoundNum:function(t){var i=Math.pow(10,(Math.floor(t)+"").length-1),e=t/i;return e=e>=10?10:e>=5?5:e>=3?3:e>=2?2:1,i*e}}),Ne=function(t){return new De(t)},je=ke.extend({options:{position:"bottomright",prefix:'Leaflet'},initialize:function(t){l(this,t),this._attributions={}},onAdd:function(t){t.attributionControl=this,this._container=ht("div","leaflet-control-attribution"),J(this._container);for(var i in t._layers)t._layers[i].getAttribution&&this.addAttribution(t._layers[i].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t?(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update(),this):this},removeAttribution:function(t){return t?(this._attributions[t]&&(this._attributions[t]--,this._update()),this):this},_update:function(){if(this._map){var t=[];for(var i in this._attributions)this._attributions[i]&&t.push(i);var e=[];this.options.prefix&&e.push(this.options.prefix),t.length&&e.push(t.join(", ")),this._container.innerHTML=e.join(" | ")}}});Se.mergeOptions({attributionControl:!0}),Se.addInitHook(function(){this.options.attributionControl&&(new je).addTo(this)});var We=function(t){return new je(t)};ke.Layers=Ie,ke.Zoom=Oe,ke.Scale=De,ke.Attribution=je,Be.layers=Ae,Be.zoom=Re,Be.scale=Ne,Be.attribution=We;var He,Fe=v.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled?this:(this._enabled=!0,this.addHooks(),this)},disable:function(){return this._enabled?(this._enabled=!1,this.removeHooks(),this):this},enabled:function(){return!!this._enabled}}),Ue={Events:xi},Ve=!1,Ge=te?"touchstart mousedown":"mousedown",qe={mousedown:"mouseup",touchstart:"touchend",pointerdown:"touchend",MSPointerDown:"touchend"},Ke={mousedown:"mousemove",touchstart:"touchmove",pointerdown:"touchmove",MSPointerDown:"touchmove"},Ye=wi.extend({options:{clickTolerance:3},initialize:function(t,i,e,n){l(this,n),this._element=t,this._dragStartTarget=i||t,this._preventOutline=e},enable:function(){this._enabled||(V(this._dragStartTarget,Ge,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(L.Draggable._dragging===this&&this.finishDrag(),G(this._dragStartTarget,Ge,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){if(!t._simulated&&this._enabled&&(this._moved=!1,!dt(this._element,"leaflet-zoom-anim")&&!(Ve||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||(Ve=this,this._preventOutline&&zt(this._element),bt(),zi(),this._moving)))){this.fire("down");var i=t.touches?t.touches[0]:t;this._startPoint=new x(i.clientX,i.clientY),V(document,Ke[t.type],this._onMove,this),V(document,qe[t.type],this._onUp,this)}},_onMove:function(t){if(!t._simulated&&this._enabled){if(t.touches&&t.touches.length>1)return void(this._moved=!0);var i=t.touches&&1===t.touches.length?t.touches[0]:t,e=new x(i.clientX,i.clientY),n=e.subtract(this._startPoint);(n.x||n.y)&&(Math.abs(n.x)+Math.abs(n.y)1e-7;h++)i=s*Math.sin(a),i=Math.pow((1-i)/(1+i),s/2),u=Math.PI/2-2*Math.atan(r*i)-a,a+=u;return new M(a*e,t.x*e/n)}},tn=(Object.freeze||Object)({LonLat:$e,Mercator:Qe,SphericalMercator:bi}),en=i({},Pi,{code:"EPSG:3395",projection:Qe,transformation:function(){var t=.5/(Math.PI*Qe.R);return E(t,.5,-t,.5)}()}),nn=i({},Pi,{code:"EPSG:4326",projection:$e,transformation:E(1/180,1,-1/180,.5)}),on=i({},Li,{projection:$e,transformation:E(1,0,-1,0),scale:function(t){return Math.pow(2,t)},zoom:function(t){return Math.log(t)/Math.LN2},distance:function(t,i){var e=i.lng-t.lng,n=i.lat-t.lat;return Math.sqrt(e*e+n*n)},infinite:!0});Li.Earth=Pi,Li.EPSG3395=en,Li.EPSG3857=Zi,Li.EPSG900913=Ei,Li.EPSG4326=nn,Li.Simple=on;var sn=wi.extend({options:{pane:"overlayPane",attribution:null,bubblingMouseEvents:!0},addTo:function(t){return t.addLayer(this),this},remove:function(){return this.removeFrom(this._map||this._mapToAdd)},removeFrom:function(t){return t&&t.removeLayer(this),this},getPane:function(t){return this._map.getPane(t?this.options[t]||t:this.options.pane)},addInteractiveTarget:function(t){return this._map._targets[n(t)]=this,this}, removeInteractiveTarget:function(t){return delete this._map._targets[n(t)],this},getAttribution:function(){return this.options.attribution},_layerAdd:function(t){var i=t.target;if(i.hasLayer(this)){if(this._map=i,this._zoomAnimated=i._zoomAnimated,this.getEvents){var e=this.getEvents();i.on(e,this),this.once("remove",function(){i.off(e,this)},this)}this.onAdd(i),this.getAttribution&&i.attributionControl&&i.attributionControl.addAttribution(this.getAttribution()),this.fire("add"),i.fire("layeradd",{layer:this})}}});Se.include({addLayer:function(t){var i=n(t);return this._layers[i]?this:(this._layers[i]=t,t._mapToAdd=this,t.beforeAdd&&t.beforeAdd(this),this.whenReady(t._layerAdd,t),this)},removeLayer:function(t){var i=n(t);return this._layers[i]?(this._loaded&&t.onRemove(this),t.getAttribution&&this.attributionControl&&this.attributionControl.removeAttribution(t.getAttribution()),delete this._layers[i],this._loaded&&(this.fire("layerremove",{layer:t}),t.fire("remove")),t._map=t._mapToAdd=null,this):this},hasLayer:function(t){return!!t&&n(t)in this._layers},eachLayer:function(t,i){for(var e in this._layers)t.call(i,this._layers[e]);return this},_addLayers:function(t){t=t?pi(t)?t:[t]:[];for(var i=0,e=t.length;ithis._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()i)return r=(n-i)/e,this._map.layerPointToLatLng([s.x-r*(s.x-o.x),s.y-r*(s.y-o.y)])},getBounds:function(){return this._bounds},addLatLng:function(t,i){return i=i||this._defaultShape(),t=C(t),i.push(t),this._bounds.extend(t),this.redraw()},_setLatLngs:function(t){this._bounds=new T,this._latlngs=this._convertLatLngs(t)},_defaultShape:function(){return jt(this._latlngs)?this._latlngs:this._latlngs[0]},_convertLatLngs:function(t){for(var i=[],e=jt(t),n=0,o=t.length;n=2&&i[0]instanceof M&&i[0].equals(i[e-1])&&i.pop(),i},_setLatLngs:function(t){gn.prototype._setLatLngs.call(this,t),jt(this._latlngs)&&(this._latlngs=[this._latlngs])},_defaultShape:function(){return jt(this._latlngs[0])?this._latlngs[0]:this._latlngs[0][0]},_clipPoints:function(){var t=this._renderer._bounds,i=this.options.weight,e=new x(i,i);if(t=new P(t.min.subtract(e),t.max.add(e)),this._parts=[],this._pxBounds&&this._pxBounds.intersects(t)){if(this.options.noClip)return void(this._parts=this._rings);for(var n,o=0,s=this._rings.length;ot.y!=n.y>t.y&&t.x<(n.x-e.x)*(t.y-e.y)/(n.y-e.y)+e.x&&(u=!u);return u||gn.prototype._containsPoint.call(this,t,!0)}}),yn=hn.extend({initialize:function(t,i){l(this,i),this._layers={},t&&this.addData(t)},addData:function(t){var i,e,n,o=pi(t)?t:t.features;if(o){for(i=0,e=o.length;io?(i.height=o+"px",pt(t,s)):mt(t,s),this._containerWidth=this._container.offsetWidth},_animateZoom:function(t){var i=this._map._latLngToNewLayerPoint(this._latlng,t.zoom,t.center),e=this._getAnchor();Lt(this._container,i.add(e))},_adjustPan:function(){if(!(!this.options.autoPan||this._map._panAnim&&this._map._panAnim._inProgress)){var t=this._map,i=parseInt(at(this._container,"marginBottom"),10)||0,e=this._container.offsetHeight+i,n=this._containerWidth,o=new x(this._containerLeft,-e-this._containerBottom);o._add(Pt(this._container));var s=t.layerPointToContainerPoint(o),r=w(this.options.autoPanPadding),a=w(this.options.autoPanPaddingTopLeft||r),h=w(this.options.autoPanPaddingBottomRight||r),u=t.getSize(),l=0,c=0;s.x+n+h.x>u.x&&(l=s.x+n-u.x+h.x),s.x-l-a.x<0&&(l=s.x-a.x),s.y+e+h.y>u.y&&(c=s.y+e-u.y+h.y),s.y-c-a.y<0&&(c=s.y-a.y),(l||c)&&t.fire("autopanstart").panBy([l,c])}},_onCloseButtonClick:function(t){this._close(),Q(t)},_getAnchor:function(){return w(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}}),Mn=function(t,i){return new zn(t,i)};Se.mergeOptions({closePopupOnClick:!0}),Se.include({openPopup:function(t,i,e){return t instanceof zn||(t=new zn(e).setContent(t)),i&&t.setLatLng(i),this.hasLayer(t)?this:(this._popup&&this._popup.options.autoClose&&this.closePopup(),this._popup=t,this.addLayer(t))},closePopup:function(t){return t&&t!==this._popup||(t=this._popup,this._popup=null),t&&this.removeLayer(t),this}}),sn.include({bindPopup:function(t,i){return t instanceof zn?(l(t,i),this._popup=t,t._source=this):(this._popup&&!i||(this._popup=new zn(i,this)),this._popup.setContent(t)),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t,i){if(t instanceof sn||(i=t,t=this),t instanceof hn)for(var e in this._layers){t=this._layers[e];break}return i||(i=t.getCenter?t.getCenter():t.getLatLng()),this._popup&&this._map&&(this._popup._source=t,this._popup.update(),this._map.openPopup(this._popup,i)),this},closePopup:function(){return this._popup&&this._popup._close(),this},togglePopup:function(t){return this._popup&&(this._popup._map?this.closePopup():this.openPopup(t)),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var i=t.layer||t.target;if(this._popup&&this._map)return Q(t),i instanceof pn?void this.openPopup(t.layer||t.target,t.latlng):void(this._map.hasLayer(this._popup)&&this._popup._source===i?this.closePopup():this.openPopup(i,t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}});var Cn=Tn.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,interactive:!1,opacity:.9},onAdd:function(t){Tn.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&this._source.fire("tooltipopen",{tooltip:this},!0)},onRemove:function(t){Tn.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&this._source.fire("tooltipclose",{tooltip:this},!0)},getEvents:function(){var t=Tn.prototype.getEvents.call(this);return te&&!this.options.permanent&&(t.preclick=this._close),t},_close:function(){this._map&&this._map.closeTooltip(this)},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=ht("div",t)},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var i=this._map,e=this._container,n=i.latLngToContainerPoint(i.getCenter()),o=i.layerPointToContainerPoint(t),s=this.options.direction,r=e.offsetWidth,a=e.offsetHeight,h=w(this.options.offset),u=this._getAnchor();"top"===s?t=t.add(w(-r/2+h.x,-a+h.y+u.y,!0)):"bottom"===s?t=t.subtract(w(r/2-h.x,-h.y,!0)):"center"===s?t=t.subtract(w(r/2+h.x,a/2-u.y+h.y,!0)):"right"===s||"auto"===s&&o.xthis.options.maxZoom||en&&this._retainParent(o,s,r,n))},_retainChildren:function(t,i,e,n){for(var o=2*t;o<2*t+2;o++)for(var s=2*i;s<2*i+2;s++){var r=new x(o,s);r.z=e+1;var a=this._tileCoordsToKey(r),h=this._tiles[a];h&&h.active?h.retain=!0:(h&&h.loaded&&(h.retain=!0),e+1this.options.maxZoom||void 0!==this.options.minZoom&&o1)return void this._setView(t,e);for(var c=o.min.y;c<=o.max.y;c++)for(var _=o.min.x;_<=o.max.x;_++){var d=new x(_,c);d.z=this._tileZoom,this._isValidTile(d)&&(this._tiles[this._tileCoordsToKey(d)]||r.push(d))}if(r.sort(function(t,i){return t.distanceTo(s)-i.distanceTo(s)}),0!==r.length){this._loading||(this._loading=!0,this.fire("loading"));var p=document.createDocumentFragment();for(_=0;_e.max.x)||!i.wrapLat&&(t.ye.max.y))return!1}if(!this.options.bounds)return!0;var n=this._tileCoordsToBounds(t);return z(this.options.bounds).overlaps(n)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToBounds:function(t){var i=this._map,e=this.getTileSize(),n=t.scaleBy(e),o=n.add(e),s=i.unproject(n,t.z),r=i.unproject(o,t.z),a=new T(s,r);return this.options.noWrap||i.wrapLatLngBounds(a),a},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var i=t.split(":"),e=new x(+i[0],+i[1]);return e.z=+i[2],e},_removeTile:function(t){var i=this._tiles[t];i&&(ut(i.el),delete this._tiles[t],this.fire("tileunload",{tile:i.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){pt(t,"leaflet-tile");var i=this.getTileSize();t.style.width=i.x+"px",t.style.height=i.y+"px",t.onselectstart=r,t.onmousemove=r,Bi&&this.options.opacity<1&&vt(t,this.options.opacity),Oi&&!Ri&&(t.style.WebkitBackfaceVisibility="hidden")},_addTile:function(t,i){var n=this._getTilePos(t),o=this._tileCoordsToKey(t),s=this.createTile(this._wrapCoords(t),e(this._tileReady,this,t));this._initTile(s),this.createTile.length<2&&f(e(this._tileReady,this,t,null,s)),Lt(s,n),this._tiles[o]={el:s,coords:t,current:!0},i.appendChild(s),this.fire("tileloadstart",{tile:s,coords:t})},_tileReady:function(t,i,n){if(this._map){i&&this.fire("tileerror",{error:i,tile:n,coords:t});var o=this._tileCoordsToKey(t);(n=this._tiles[o])&&(n.loaded=+new Date,this._map._fadeAnimated?(vt(n.el,0),g(this._fadeFrame),this._fadeFrame=f(this._updateOpacity,this)):(n.active=!0,this._pruneTiles()),i||(pt(n.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:n.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),Bi||!this._map._fadeAnimated?f(this._pruneTiles,this):setTimeout(e(this._pruneTiles,this),250)))}},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var i=new x(this._wrapX?s(t.x,this._wrapX):t.x,this._wrapY?s(t.y,this._wrapY):t.y);return i.z=t.z,i},_pxBoundsToTileRange:function(t){var i=this.getTileSize();return new P(t.min.unscaleBy(i).floor(),t.max.unscaleBy(i).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}}),kn=Sn.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1},initialize:function(t,i){this._url=t,i=l(this,i),i.detectRetina&&ne&&i.maxZoom>0&&(i.tileSize=Math.floor(i.tileSize/2),i.zoomReverse?(i.zoomOffset--,i.minZoom++):(i.zoomOffset++,i.maxZoom--),i.minZoom=Math.max(0,i.minZoom)),"string"==typeof i.subdomains&&(i.subdomains=i.subdomains.split("")),Oi||this.on("tileunload",this._onTileRemove)},setUrl:function(t,i){return this._url=t,i||this.redraw(),this},createTile:function(t,i){var n=document.createElement("img");return V(n,"load",e(this._tileOnLoad,this,i,n)),V(n,"error",e(this._tileOnError,this,i,n)),this.options.crossOrigin&&(n.crossOrigin=""),n.alt="",n.setAttribute("role","presentation"),n.src=this.getTileUrl(t),n},getTileUrl:function(t){var e={r:ne?"@2x":"",s:this._getSubdomain(t),x:t.x,y:t.y,z:this._getZoomForUrl()};if(this._map&&!this._map.options.crs.infinite){var n=this._globalTileRange.max.y-t.y;this.options.tms&&(e.y=n),e["-y"]=n}return _(this._url,i(e,this.options))},_tileOnLoad:function(t,i){Bi?setTimeout(e(t,this,null,i),0):t(null,i)},_tileOnError:function(t,i,e){var n=this.options.errorTileUrl;n&&i.src!==n&&(i.src=n),t(e,i)},_onTileRemove:function(t){t.tile.onload=null},_getZoomForUrl:function(){var t=this._tileZoom,i=this.options.maxZoom,e=this.options.zoomReverse,n=this.options.zoomOffset;return e&&(t=i-t),t+n},_getSubdomain:function(t){var i=Math.abs(t.x+t.y)%this.options.subdomains.length;return this.options.subdomains[i]},_abortLoading:function(){var t,i;for(t in this._tiles)this._tiles[t].coords.z!==this._tileZoom&&(i=this._tiles[t].el,i.onload=r,i.onerror=r,i.complete||(i.src=mi,ut(i)))}}),Bn=kn.extend({defaultWmsParams:{service:"WMS",request:"GetMap",layers:"",styles:"",format:"image/jpeg",transparent:!1,version:"1.1.1"},options:{crs:null,uppercase:!1},initialize:function(t,e){this._url=t;var n=i({},this.defaultWmsParams);for(var o in e)o in this.options||(n[o]=e[o]);e=l(this,e),n.width=n.height=e.tileSize*(e.detectRetina&&ne?2:1),this.wmsParams=n},onAdd:function(t){this._crs=this.options.crs||t.options.crs,this._wmsVersion=parseFloat(this.wmsParams.version);var i=this._wmsVersion>=1.3?"crs":"srs";this.wmsParams[i]=this._crs.code,kn.prototype.onAdd.call(this,t)},getTileUrl:function(t){var i=this._tileCoordsToBounds(t),e=this._crs.project(i.getNorthWest()),n=this._crs.project(i.getSouthEast()),o=(this._wmsVersion>=1.3&&this._crs===nn?[n.y,e.x,e.y,n.x]:[e.x,n.y,n.x,e.y]).join(","),s=kn.prototype.getTileUrl.call(this,t);return s+c(this.wmsParams,s,this.options.uppercase)+(this.options.uppercase?"&BBOX=":"&bbox=")+o},setParams:function(t,e){return i(this.wmsParams,t),e||this.redraw(),this}});kn.WMS=Bn,si.wms=ri;var In=sn.extend({options:{padding:.1},initialize:function(t){l(this,t),n(this),this._layers=this._layers||{}},onAdd:function(){this._container||(this._initContainer(),this._zoomAnimated&&pt(this._container,"leaflet-zoom-animated")),this.getPane().appendChild(this._container),this._update(),this.on("update",this._updatePaths,this)},onRemove:function(){this.off("update",this._updatePaths,this),this._destroyContainer()},getEvents:function(){var t={viewreset:this._reset,zoom:this._onZoom,moveend:this._update,zoomend:this._onZoomEnd};return this._zoomAnimated&&(t.zoomanim=this._onAnimZoom),t},_onAnimZoom:function(t){this._updateTransform(t.center,t.zoom)},_onZoom:function(){this._updateTransform(this._map.getCenter(),this._map.getZoom())},_updateTransform:function(t,i){var e=this._map.getZoomScale(i,this._zoom),n=Pt(this._container),o=this._map.getSize().multiplyBy(.5+this.options.padding),s=this._map.project(this._center,i),r=this._map.project(t,i),a=r.subtract(s),h=o.multiplyBy(-e).add(n).add(o).subtract(a);Ki?wt(this._container,h,e):Lt(this._container,h)},_reset:function(){this._update(),this._updateTransform(this._center,this._zoom);for(var t in this._layers)this._layers[t]._reset()},_onZoomEnd:function(){for(var t in this._layers)this._layers[t]._project()},_updatePaths:function(){for(var t in this._layers)this._layers[t]._update()},_update:function(){var t=this.options.padding,i=this._map.getSize(),e=this._map.containerPointToLayerPoint(i.multiplyBy(-t)).round();this._bounds=new P(e,e.add(i.multiplyBy(1+2*t)).round()),this._center=this._map.getCenter(),this._zoom=this._map.getZoom()}}),An=In.extend({getEvents:function(){var t=In.prototype.getEvents.call(this);return t.viewprereset=this._onViewPreReset,t},_onViewPreReset:function(){this._postponeUpdatePaths=!0},onAdd:function(){In.prototype.onAdd.call(this),this._draw()},_initContainer:function(){var t=this._container=document.createElement("canvas");V(t,"mousemove",o(this._onMouseMove,32,this),this),V(t,"click dblclick mousedown mouseup contextmenu",this._onClick,this),V(t,"mouseout",this._handleMouseOut,this),this._ctx=t.getContext("2d")},_destroyContainer:function(){delete this._ctx,ut(this._container),G(this._container),delete this._container},_updatePaths:function(){if(!this._postponeUpdatePaths){var t;this._redrawBounds=null;for(var i in this._layers)t=this._layers[i],t._update();this._redraw()}},_update:function(){if(!this._map._animatingZoom||!this._bounds){this._drawnLayers={},In.prototype._update.call(this);var t=this._bounds,i=this._container,e=t.getSize(),n=ne?2:1;Lt(i,t.min),i.width=n*e.x,i.height=n*e.y,i.style.width=e.x+"px",i.style.height=e.y+"px",ne&&this._ctx.scale(2,2),this._ctx.translate(-t.min.x,-t.min.y),this.fire("update")}},_reset:function(){In.prototype._reset.call(this),this._postponeUpdatePaths&&(this._postponeUpdatePaths=!1,this._updatePaths())},_initPath:function(t){this._updateDashArray(t),this._layers[n(t)]=t;var i=t._order={layer:t,prev:this._drawLast,next:null};this._drawLast&&(this._drawLast.next=i),this._drawLast=i,this._drawFirst=this._drawFirst||this._drawLast},_addPath:function(t){this._requestRedraw(t)},_removePath:function(t){var i=t._order,e=i.next,n=i.prev;e?e.prev=n:this._drawLast=n,n?n.next=e:this._drawFirst=e,delete t._order,delete this._layers[L.stamp(t)],this._requestRedraw(t)},_updatePath:function(t){this._extendRedrawBounds(t),t._project(),t._update(),this._requestRedraw(t)},_updateStyle:function(t){this._updateDashArray(t),this._requestRedraw(t)},_updateDashArray:function(t){if(t.options.dashArray){var i,e=t.options.dashArray.split(","),n=[];for(i=0;i')}}catch(t){return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}}(),Rn={_initContainer:function(){this._container=ht("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(In.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var i=t._container=On("shape");pt(i,"leaflet-vml-shape "+(this.options.className||"")),i.coordsize="1 1",t._path=On("path"),i.appendChild(t._path),this._updateStyle(t),this._layers[n(t)]=t},_addPath:function(t){var i=t._container;this._container.appendChild(i),t.options.interactive&&t.addInteractiveTarget(i)},_removePath:function(t){var i=t._container;ut(i),t.removeInteractiveTarget(i),delete this._layers[n(t)]},_updateStyle:function(t){var i=t._stroke,e=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(i||(i=t._stroke=On("stroke")),o.appendChild(i),i.weight=n.weight+"px",i.color=n.color,i.opacity=n.opacity,n.dashArray?i.dashStyle=pi(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):i.dashStyle="",i.endcap=n.lineCap.replace("butt","flat"),i.joinstyle=n.lineJoin):i&&(o.removeChild(i),t._stroke=null),n.fill?(e||(e=t._fill=On("fill")),o.appendChild(e),e.color=n.fillColor||n.color,e.opacity=n.fillOpacity):e&&(o.removeChild(e),t._fill=null)},_updateCircle:function(t){var i=t._point.round(),e=Math.round(t._radius),n=Math.round(t._radiusY||e);this._setPath(t,t._empty()?"M0 0":"AL "+i.x+","+i.y+" "+e+","+n+" 0,23592600")},_setPath:function(t,i){t._path.v=i},_bringToFront:function(t){ct(t._container)},_bringToBack:function(t){_t(t._container)}},Dn=re?On:S,Nn=In.extend({getEvents:function(){var t=In.prototype.getEvents.call(this);return t.zoomstart=this._onZoomStart,t},_initContainer:function(){this._container=Dn("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=Dn("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){ut(this._container),G(this._container),delete this._container,delete this._rootGroup},_onZoomStart:function(){this._update()},_update:function(){if(!this._map._animatingZoom||!this._bounds){In.prototype._update.call(this);var t=this._bounds,i=t.getSize(),e=this._container;this._svgSize&&this._svgSize.equals(i)||(this._svgSize=i,e.setAttribute("width",i.x),e.setAttribute("height",i.y)),Lt(e,t.min),e.setAttribute("viewBox",[t.min.x,t.min.y,i.x,i.y].join(" ")),this.fire("update")}},_initPath:function(t){var i=t._path=Dn("path");t.options.className&&pt(i,t.options.className),t.options.interactive&&pt(i,"leaflet-interactive"),this._updateStyle(t),this._layers[n(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){ut(t._path),t.removeInteractiveTarget(t._path),delete this._layers[n(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var i=t._path,e=t.options;i&&(e.stroke?(i.setAttribute("stroke",e.color),i.setAttribute("stroke-opacity",e.opacity),i.setAttribute("stroke-width",e.weight),i.setAttribute("stroke-linecap",e.lineCap),i.setAttribute("stroke-linejoin",e.lineJoin),e.dashArray?i.setAttribute("stroke-dasharray",e.dashArray):i.removeAttribute("stroke-dasharray"),e.dashOffset?i.setAttribute("stroke-dashoffset",e.dashOffset):i.removeAttribute("stroke-dashoffset")):i.setAttribute("stroke","none"),e.fill?(i.setAttribute("fill",e.fillColor||e.color),i.setAttribute("fill-opacity",e.fillOpacity),i.setAttribute("fill-rule",e.fillRule||"evenodd")):i.setAttribute("fill","none"))},_updatePoly:function(t,i){this._setPath(t,k(t._parts,i))},_updateCircle:function(t){var i=t._point,e=t._radius,n=t._radiusY||e,o="a"+e+","+n+" 0 1,0 ",s=t._empty()?"M0 0":"M"+(i.x-e)+","+i.y+o+2*e+",0 "+o+2*-e+",0 ";this._setPath(t,s)},_setPath:function(t,i){t._path.setAttribute("d",i)},_bringToFront:function(t){ct(t._path)},_bringToBack:function(t){_t(t._path)}});re&&Nn.include(Rn),Se.include({getRenderer:function(t){var i=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer;return i||(i=this._renderer=this.options.preferCanvas&&ai()||hi()),this.hasLayer(i)||this.addLayer(i),i},_getPaneRenderer:function(t){if("overlayPane"===t||void 0===t)return!1;var i=this._paneRenderers[t];return void 0===i&&(i=Nn&&hi({pane:t})||An&&ai({pane:t}),this._paneRenderers[t]=i),i}});var jn=vn.extend({initialize:function(t,i){vn.prototype.initialize.call(this,this._boundsToLatLngs(t),i)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return t=z(t),[t.getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Nn.create=Dn,Nn.pointsToPath=k,yn.geometryToLayer=Kt,yn.coordsToLatLng=Yt,yn.coordsToLatLngs=Xt,yn.latLngToCoords=Jt,yn.latLngsToCoords=$t,yn.getFeature=Qt,yn.asFeature=ti,Se.mergeOptions({boxZoom:!0});var Wn=Fe.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){V(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){G(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){ut(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){return!(!t.shiftKey||1!==t.which&&1!==t.button)&&(this._clearDeferredResetState(),this._resetState(),zi(),bt(),this._startPoint=this._map.mouseEventToContainerPoint(t),void V(document,{contextmenu:Q,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this))},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=ht("div","leaflet-zoom-box",this._container),pt(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var i=new P(this._point,this._startPoint),e=i.getSize();Lt(this._box,i.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(ut(this._box),mt(this._container,"leaflet-crosshair")),Mi(),Tt(),G(document,{contextmenu:Q,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){if((1===t.which||1===t.button)&&(this._finish(),this._moved)){this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(e(this._resetState,this),0);var i=new T(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point));this._map.fitBounds(i).fire("boxzoomend",{boxZoomBounds:i})}},_onKeyDown:function(t){27===t.keyCode&&this._finish()}});Se.addInitHook("addHandler","boxZoom",Wn),Se.mergeOptions({doubleClickZoom:!0});var Hn=Fe.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var i=this._map,e=i.getZoom(),n=i.options.zoomDelta,o=t.originalEvent.shiftKey?e-n:e+n;"center"===i.options.doubleClickZoom?i.setZoom(o):i.setZoomAround(t.containerPoint,o)}});Se.addInitHook("addHandler","doubleClickZoom",Hn),Se.mergeOptions({dragging:!0,inertia:!Ri,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var Fn=Fe.extend({addHooks:function(){if(!this._draggable){var t=this._map;this._draggable=new Ye(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))}pt(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){mt(this._map._container,"leaflet-grab"),mt(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t=this._map;if(t._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity){var i=z(this._map.options.maxBounds);this._offsetLimit=b(this._map.latLngToContainerPoint(i.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(i.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))}else this._offsetLimit=null;t.fire("movestart").fire("dragstart"),t.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){if(this._map.options.inertia){var i=this._lastTime=+new Date,e=this._lastPos=this._draggable._absPos||this._draggable._newPos;this._positions.push(e),this._times.push(i),i-this._times[0]>50&&(this._positions.shift(),this._times.shift())}this._map.fire("move",t).fire("drag",t)},_onZoomEnd:function(){var t=this._map.getSize().divideBy(2),i=this._map.latLngToLayerPoint([0,0]);this._initialWorldOffset=i.subtract(t).x,this._worldWidth=this._map.getPixelWorldBounds().getSize().x},_viscousLimit:function(t,i){return t-(t-i)*this._viscosity},_onPreDragLimit:function(){if(this._viscosity&&this._offsetLimit){var t=this._draggable._newPos.subtract(this._draggable._startPos),i=this._offsetLimit;t.xi.max.x&&(t.x=this._viscousLimit(t.x,i.max.x)),t.y>i.max.y&&(t.y=this._viscousLimit(t.y,i.max.y)),this._draggable._newPos=this._draggable._startPos.add(t)}},_onPreDragWrap:function(){ -var t=this._worldWidth,i=Math.round(t/2),e=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-i+e)%t+i-e,s=(n+i+e)%t-i-e,r=Math.abs(o+e)0?s:-s))-i;this._delta=0,this._startTime=null,r&&("center"===t.options.scrollWheelZoom?t.setZoom(i+r):t.setZoomAround(this._lastMousePos,i+r))}});Se.addInitHook("addHandler","scrollWheelZoom",Vn),Se.mergeOptions({tap:!0,tapTolerance:15});var Gn=Fe.extend({addHooks:function(){V(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){G(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(t.touches){if($(t),this._fireClick=!0,t.touches.length>1)return this._fireClick=!1,void clearTimeout(this._holdTimeout);var i=t.touches[0],n=i.target;this._startPos=this._newPos=new x(i.clientX,i.clientY),n.tagName&&"a"===n.tagName.toLowerCase()&&pt(n,"leaflet-active"),this._holdTimeout=setTimeout(e(function(){this._isTapValid()&&(this._fireClick=!1,this._onUp(),this._simulateEvent("contextmenu",i))},this),1e3),this._simulateEvent("mousedown",i),V(document,{touchmove:this._onMove,touchend:this._onUp},this)}},_onUp:function(t){if(clearTimeout(this._holdTimeout),G(document,{touchmove:this._onMove,touchend:this._onUp},this),this._fireClick&&t&&t.changedTouches){var i=t.changedTouches[0],e=i.target;e&&e.tagName&&"a"===e.tagName.toLowerCase()&&mt(e,"leaflet-active"),this._simulateEvent("mouseup",i),this._isTapValid()&&this._simulateEvent("click",i)}},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_onMove:function(t){var i=t.touches[0];this._newPos=new x(i.clientX,i.clientY),this._simulateEvent("mousemove",i)},_simulateEvent:function(t,i){var e=document.createEvent("MouseEvents");e._simulated=!0,i.target._simulatedClick=!0,e.initMouseEvent(t,!0,!0,window,1,i.screenX,i.screenY,i.clientX,i.clientY,!1,!1,!1,!1,0,null),i.target.dispatchEvent(e)}});te&&!Qi&&Se.addInitHook("addHandler","tap",Gn),Se.mergeOptions({touchZoom:te&&!Ri,bounceAtZoomLimits:!0});var qn=Fe.extend({addHooks:function(){pt(this._map._container,"leaflet-touch-zoom"),V(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){mt(this._map._container,"leaflet-touch-zoom"),G(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var i=this._map;if(t.touches&&2===t.touches.length&&!i._animatingZoom&&!this._zooming){var e=i.mouseEventToContainerPoint(t.touches[0]),n=i.mouseEventToContainerPoint(t.touches[1]);this._centerPoint=i.getSize()._divideBy(2),this._startLatLng=i.containerPointToLatLng(this._centerPoint),"center"!==i.options.touchZoom&&(this._pinchStartLatLng=i.containerPointToLatLng(e.add(n)._divideBy(2))),this._startDist=e.distanceTo(n),this._startZoom=i.getZoom(),this._moved=!1,this._zooming=!0,i._stop(),V(document,"touchmove",this._onTouchMove,this),V(document,"touchend",this._onTouchEnd,this),$(t)}},_onTouchMove:function(t){if(t.touches&&2===t.touches.length&&this._zooming){var i=this._map,n=i.mouseEventToContainerPoint(t.touches[0]),o=i.mouseEventToContainerPoint(t.touches[1]),s=n.distanceTo(o)/this._startDist;if(this._zoom=i.getScaleZoom(s,this._startZoom),!i.options.bounceAtZoomLimits&&(this._zoomi.getMaxZoom()&&s>1)&&(this._zoom=i._limitZoom(this._zoom)),"center"===i.options.touchZoom){if(this._center=this._startLatLng,1===s)return}else{var r=n._add(o)._divideBy(2)._subtract(this._centerPoint);if(1===s&&0===r.x&&0===r.y)return;this._center=i.unproject(i.project(this._pinchStartLatLng,this._zoom).subtract(r),this._zoom)}this._moved||(i._moveStart(!0),this._moved=!0),g(this._animRequest);var a=e(i._move,i,this._center,this._zoom,{pinch:!0,round:!1});this._animRequest=f(a,this,!0),$(t)}},_onTouchEnd:function(){return this._moved&&this._zooming?(this._zooming=!1,g(this._animRequest),G(document,"touchmove",this._onTouchMove),G(document,"touchend",this._onTouchEnd),void(this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom)))):void(this._zooming=!1)}});Se.addInitHook("addHandler","touchZoom",qn),Se.BoxZoom=Wn,Se.DoubleClickZoom=Hn,Se.Drag=Fn,Se.Keyboard=Un,Se.ScrollWheelZoom=Vn,Se.Tap=Gn,Se.TouchZoom=qn;var Kn=window.L;window.L=t,t.version="1.1.0",t.noConflict=li,t.Control=ke,t.control=Be,t.Browser=ae,t.Evented=wi,t.Mixin=Ue,t.Util=yi,t.Class=v,t.Handler=Fe,t.extend=i,t.bind=e,t.stamp=n,t.setOptions=l,t.DomEvent=Le,t.DomUtil=Ze,t.PosAnimation=Ee,t.Draggable=Ye,t.LineUtil=Xe,t.PolyUtil=Je,t.Point=x,t.point=w,t.Bounds=P,t.bounds=b,t.Transformation=Z,t.transformation=E,t.Projection=tn,t.LatLng=M,t.latLng=C,t.LatLngBounds=T,t.latLngBounds=z,t.CRS=Li,t.GeoJSON=yn,t.geoJSON=ii,t.geoJson=wn,t.Layer=sn,t.LayerGroup=rn,t.layerGroup=an,t.FeatureGroup=hn,t.featureGroup=un,t.ImageOverlay=Ln,t.imageOverlay=Pn,t.VideoOverlay=bn,t.videoOverlay=ei,t.DivOverlay=Tn,t.Popup=zn,t.popup=Mn,t.Tooltip=Cn,t.tooltip=Zn,t.Icon=ln,t.icon=Ht,t.DivIcon=En,t.divIcon=ni,t.Marker=dn,t.marker=Ft,t.TileLayer=kn,t.tileLayer=si,t.GridLayer=Sn,t.gridLayer=oi,t.SVG=Nn,t.svg=hi,t.Renderer=In,t.Canvas=An,t.canvas=ai,t.Path=pn,t.CircleMarker=mn,t.circleMarker=Ut,t.Circle=fn,t.circle=Vt,t.Polyline=gn,t.polyline=Gt,t.Polygon=vn,t.polygon=qt,t.Rectangle=jn,t.rectangle=ui,t.Map=Se,t.map=Ct}) \ No newline at end of file +var t=this._worldWidth,i=Math.round(t/2),e=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-i+e)%t+i-e,s=(n+i+e)%t-i-e,r=Math.abs(o+e)0?s:-s))-i;this._delta=0,this._startTime=null,r&&("center"===t.options.scrollWheelZoom?t.setZoom(i+r):t.setZoomAround(this._lastMousePos,i+r))}});Se.addInitHook("addHandler","scrollWheelZoom",Vn),Se.mergeOptions({tap:!0,tapTolerance:15});var Gn=Fe.extend({addHooks:function(){V(this._map._container,"touchstart",this._onDown,this)},removeHooks:function(){G(this._map._container,"touchstart",this._onDown,this)},_onDown:function(t){if(t.touches){if($(t),this._fireClick=!0,t.touches.length>1)return this._fireClick=!1,void clearTimeout(this._holdTimeout);var i=t.touches[0],n=i.target;this._startPos=this._newPos=new x(i.clientX,i.clientY),n.tagName&&"a"===n.tagName.toLowerCase()&&pt(n,"leaflet-active"),this._holdTimeout=setTimeout(e(function(){this._isTapValid()&&(this._fireClick=!1,this._onUp(),this._simulateEvent("contextmenu",i))},this),1e3),this._simulateEvent("mousedown",i),V(document,{touchmove:this._onMove,touchend:this._onUp},this)}},_onUp:function(t){if(clearTimeout(this._holdTimeout),G(document,{touchmove:this._onMove,touchend:this._onUp},this),this._fireClick&&t&&t.changedTouches){var i=t.changedTouches[0],e=i.target;e&&e.tagName&&"a"===e.tagName.toLowerCase()&&mt(e,"leaflet-active"),this._simulateEvent("mouseup",i),this._isTapValid()&&this._simulateEvent("click",i)}},_isTapValid:function(){return this._newPos.distanceTo(this._startPos)<=this._map.options.tapTolerance},_onMove:function(t){var i=t.touches[0];this._newPos=new x(i.clientX,i.clientY),this._simulateEvent("mousemove",i)},_simulateEvent:function(t,i){var e=document.createEvent("MouseEvents");e._simulated=!0,i.target._simulatedClick=!0,e.initMouseEvent(t,!0,!0,window,1,i.screenX,i.screenY,i.clientX,i.clientY,!1,!1,!1,!1,0,null),i.target.dispatchEvent(e)}});te&&!Qi&&Se.addInitHook("addHandler","tap",Gn),Se.mergeOptions({touchZoom:te&&!Ri,bounceAtZoomLimits:!0});var qn=Fe.extend({addHooks:function(){pt(this._map._container,"leaflet-touch-zoom"),V(this._map._container,"touchstart",this._onTouchStart,this)},removeHooks:function(){mt(this._map._container,"leaflet-touch-zoom"),G(this._map._container,"touchstart",this._onTouchStart,this)},_onTouchStart:function(t){var i=this._map;if(t.touches&&2===t.touches.length&&!i._animatingZoom&&!this._zooming){var e=i.mouseEventToContainerPoint(t.touches[0]),n=i.mouseEventToContainerPoint(t.touches[1]);this._centerPoint=i.getSize()._divideBy(2),this._startLatLng=i.containerPointToLatLng(this._centerPoint),"center"!==i.options.touchZoom&&(this._pinchStartLatLng=i.containerPointToLatLng(e.add(n)._divideBy(2))),this._startDist=e.distanceTo(n),this._startZoom=i.getZoom(),this._moved=!1,this._zooming=!0,i._stop(),V(document,"touchmove",this._onTouchMove,this),V(document,"touchend",this._onTouchEnd,this),$(t)}},_onTouchMove:function(t){if(t.touches&&2===t.touches.length&&this._zooming){var i=this._map,n=i.mouseEventToContainerPoint(t.touches[0]),o=i.mouseEventToContainerPoint(t.touches[1]),s=n.distanceTo(o)/this._startDist;if(this._zoom=i.getScaleZoom(s,this._startZoom),!i.options.bounceAtZoomLimits&&(this._zoomi.getMaxZoom()&&s>1)&&(this._zoom=i._limitZoom(this._zoom)),"center"===i.options.touchZoom){if(this._center=this._startLatLng,1===s)return}else{var r=n._add(o)._divideBy(2)._subtract(this._centerPoint);if(1===s&&0===r.x&&0===r.y)return;this._center=i.unproject(i.project(this._pinchStartLatLng,this._zoom).subtract(r),this._zoom)}this._moved||(i._moveStart(!0),this._moved=!0),g(this._animRequest);var a=e(i._move,i,this._center,this._zoom,{pinch:!0,round:!1});this._animRequest=f(a,this,!0),$(t)}},_onTouchEnd:function(){return this._moved&&this._zooming?(this._zooming=!1,g(this._animRequest),G(document,"touchmove",this._onTouchMove),G(document,"touchend",this._onTouchEnd),void(this._map.options.zoomAnimation?this._map._animateZoom(this._center,this._map._limitZoom(this._zoom),!0,this._map.options.zoomSnap):this._map._resetView(this._center,this._map._limitZoom(this._zoom)))):void(this._zooming=!1)}});Se.addInitHook("addHandler","touchZoom",qn),Se.BoxZoom=Wn,Se.DoubleClickZoom=Hn,Se.Drag=Fn,Se.Keyboard=Un,Se.ScrollWheelZoom=Vn,Se.Tap=Gn,Se.TouchZoom=qn;var Kn=window.L;window.L=t,t.version="1.1.0",t.noConflict=li,t.Control=ke,t.control=Be,t.Browser=ae,t.Evented=wi,t.Mixin=Ue,t.Util=yi,t.Class=v,t.Handler=Fe,t.extend=i,t.bind=e,t.stamp=n,t.setOptions=l,t.DomEvent=Le,t.DomUtil=Ze,t.PosAnimation=Ee,t.Draggable=Ye,t.LineUtil=Xe,t.PolyUtil=Je,t.Point=x,t.point=w,t.Bounds=P,t.bounds=b,t.Transformation=Z,t.transformation=E,t.Projection=tn,t.LatLng=M,t.latLng=C,t.LatLngBounds=T,t.latLngBounds=z,t.CRS=Li,t.GeoJSON=yn,t.geoJSON=ii,t.geoJson=wn,t.Layer=sn,t.LayerGroup=rn,t.layerGroup=an,t.FeatureGroup=hn,t.featureGroup=un,t.ImageOverlay=Ln,t.imageOverlay=Pn,t.VideoOverlay=bn,t.videoOverlay=ei,t.DivOverlay=Tn,t.Popup=zn,t.popup=Mn,t.Tooltip=Cn,t.tooltip=Zn,t.Icon=ln,t.icon=Ht,t.DivIcon=En,t.divIcon=ni,t.Marker=dn,t.marker=Ft,t.TileLayer=kn,t.tileLayer=si,t.GridLayer=Sn,t.gridLayer=oi,t.SVG=Nn,t.svg=hi,t.Renderer=In,t.Canvas=An,t.canvas=ai,t.Path=pn,t.CircleMarker=mn,t.circleMarker=Ut,t.Circle=fn,t.circle=Vt,t.Polyline=gn,t.polyline=Gt,t.Polygon=vn,t.polygon=qt,t.Rectangle=jn,t.rectangle=ui,t.Map=Se,t.map=Ct}) \ 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 a5e2b5e4fff..7f5a96d3c93 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/panels/ha-panel-shopping-list.html b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html new file mode 100644 index 00000000000..0eb37f1a00d --- /dev/null +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html @@ -0,0 +1,111 @@ + \ No newline at end of file diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz new file mode 100644 index 00000000000..687f3905683 Binary files /dev/null and b/homeassistant/components/frontend/www_static/panels/ha-panel-shopping-list.html.gz differ diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html index dd54aa1192f..884618cd34c 100644 --- a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html +++ b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.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-zwave.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz index 93eae9a0374..9c93970e336 100644 Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz and b/homeassistant/components/frontend/www_static/panels/ha-panel-zwave.html.gz differ diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js index 2a123e7a0ab..173132fd3a9 100644 --- a/homeassistant/components/frontend/www_static/service_worker.js +++ b/homeassistant/components/frontend/www_static/service_worker.js @@ -1 +1 @@ -"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","78ea41075e1cab2bdc72e474bc905291"],["/frontend/panels/dev-event-4886c821235492b1b92739b580d21c61.html","0f16df49a7d965ddc1fd55f7bd3ffd3f"],["/frontend/panels/dev-info-24e888ec7a8acd0c395b34396e9001bc.html","7bb116813e8dbab7bcfabdf4de3ec83f"],["/frontend/panels/dev-service-ac2c50e486927dc4443e93d79f08c06e.html","1daf8c159d2fab036f7094b0e737f1a0"],["/frontend/panels/dev-state-8f1a27c04db6329d31cfcc7d0d6a0869.html","002ea95ab67f5c06f9112008a81e571b"],["/frontend/panels/dev-template-82cd543177c417e5c6612e07df851e6b.html","81c4dbc540739dcf49c351cf565db71b"],["/frontend/panels/map-d3dae1400ec4e4cd7681d2aa79131d55.html","19102e8bcbe3735db7358068b17f323a"],["/static/compatibility-8e4c44b5f4288cc48ec1ba94a9bec812.js","4704a985ad259e324c3d8a0a40f6d937"],["/static/core-d4a7cb8c80c62b536764e0e81385f6aa.js","37e34ec6aa0fa155c7d50e2883be1ead"],["/static/frontend-a7d4cb8260e8094342b5bd8c36c4bf5b.html","73343b5bed6f016b2ba7ff8ee4a1e219"],["/static/mdi-c92bd28c434865d6cabb34cd3c0a3e4c.html","7e24a0584d139fef75d7678ef3c3b008"],["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"],["static/webcomponents-lite.min.js","32b5a9b7ada86304bec6b43d3f2194f0"]],cacheName="sw-precache-v3--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var n=new URL(e);return"/"===n.pathname.slice(-1)&&(n.pathname+=t),n.toString()},cleanResponse=function(e){return e.redirected?("body"in e?Promise.resolve(e.body):e.blob()).then(function(t){return new Response(t,{headers:e.headers,status:e.status,statusText:e.statusText})}):Promise.resolve(e)},createCacheKey=function(e,t,n,a){var c=new URL(e);return a&&c.pathname.match(a)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(n)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var n=new URL(t).pathname;return e.some(function(e){return n.match(e)})},stripIgnoredUrlParameters=function(e,t){var n=new URL(e);return n.search=n.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),n.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],n=e[1],a=new URL(t,self.location),c=createCacheKey(a,hashParamName,n,!1);return[a.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(n){if(!t.has(n)){var a=new Request(n,{credentials:"same-origin"});return fetch(a).then(function(t){if(!t.ok)throw new Error("Request for "+n+" returned a response with status "+t.status);return cleanResponse(t).then(function(t){return e.put(n,t)})})}}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(n){return Promise.all(n.map(function(n){if(!t.has(n.url))return e.delete(n)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,n=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(n);t||(n=addDirectoryIndex(n,"index.html"),t=urlsToCacheKeys.has(n));!t&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(n=new URL("/",self.location).toString(),t=urlsToCacheKeys.has(n)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(n)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url)&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var n,a;for(n=0;nYou will need to restart hass after fixing.' - ''.format(err), + hass.components.persistent_notification.create( + 'Error: {}
You will need to restart hass after fixing.' + ''.format(err), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) return False - persistent_notification.create( - hass, 'In order to authorize Home-Assistant to view your calendars ' - 'you must visit: {} and enter ' - 'code: {}'.format(dev_flow.verification_url, - dev_flow.verification_url, - dev_flow.user_code), + hass.components.persistent_notification.create( + 'In order to authorize Home-Assistant to view your calendars ' + 'you must visit: {} and enter ' + 'code: {}'.format(dev_flow.verification_url, + dev_flow.verification_url, + dev_flow.user_code), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID ) def step2_exchange(now): """Keep trying to validate the user_code until it expires.""" if now >= dt.as_local(dev_flow.user_code_expiry): - persistent_notification.create( - hass, 'Authenication code expired, please restart ' - 'Home-Assistant and try again', + hass.components.persistent_notification.create( + 'Authenication code expired, please restart ' + 'Home-Assistant and try again', title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) listener() @@ -146,9 +144,9 @@ def do_authentication(hass, config): storage.put(credentials) do_setup(hass, config) listener() - persistent_notification.create( - hass, 'We are all setup now. Check {} for calendars that have ' - 'been found'.format(YAML_DEVICES), + hass.components.persistent_notification.create( + 'We are all setup now. Check {} for calendars that have ' + 'been found'.format(YAML_DEVICES), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) listener = track_time_change(hass, step2_exchange, diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py index d07e506e897..9985d129a3a 100644 --- a/homeassistant/components/group.py +++ b/homeassistant/components/group.py @@ -17,6 +17,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, ATTR_ASSUMED_STATE, SERVICE_RELOAD) from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change @@ -108,6 +109,7 @@ def _get_group_on_off(state): return None, None +@bind_hass def is_on(hass, entity_id): """Test if the group state is in its ON-state.""" state = hass.states.get(entity_id) @@ -121,23 +123,27 @@ def is_on(hass, entity_id): return False +@bind_hass def reload(hass): """Reload the automation from config.""" hass.add_job(async_reload, hass) @callback +@bind_hass def async_reload(hass): """Reload the automation from config.""" hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_RELOAD)) +@bind_hass def set_visibility(hass, entity_id=None, visible=True): """Hide or shows a group.""" data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible} hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data) +@bind_hass def set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): """Create a new user group.""" @@ -147,6 +153,7 @@ def set_group(hass, object_id, name=None, entity_ids=None, visible=None, @callback +@bind_hass def async_set_group(hass, object_id, name=None, entity_ids=None, visible=None, icon=None, view=None, control=None, add=None): """Create a new user group.""" @@ -166,18 +173,21 @@ def async_set_group(hass, object_id, name=None, entity_ids=None, visible=None, hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SET, data)) +@bind_hass def remove(hass, name): """Remove a user group.""" hass.add_job(async_remove, hass, name) @callback +@bind_hass def async_remove(hass, object_id): """Remove a user group.""" data = {ATTR_OBJECT_ID: object_id} hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data)) +@bind_hass def expand_entity_ids(hass, entity_ids): """Return entity_ids with group entity ids replaced by their members. @@ -215,6 +225,7 @@ def expand_entity_ids(hass, entity_ids): return found_ids +@bind_hass def get_entity_ids(hass, entity_id, domain_filter=None): """Get members of this group. diff --git a/homeassistant/components/hassio.py b/homeassistant/components/hassio.py index 4ce60fed014..1ba599c72b4 100644 --- a/homeassistant/components/hassio.py +++ b/homeassistant/components/hassio.py @@ -25,8 +25,15 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'hassio' DEPENDENCIES = ['http'] -TIMEOUT = 10 -NO_TIMEOUT = set(['homeassistant/update', 'host/update', 'supervisor/update']) +NO_TIMEOUT = { + re.compile(r'^homeassistant/update$'), re.compile(r'^host/update$'), + re.compile(r'^supervisor/update$'), re.compile(r'^addons/[^/]*/update$'), + re.compile(r'^addons/[^/]*/install$') +} + +NO_AUTH = { + re.compile(r'^panel$'), re.compile(r'^addons/[^/]*/logo$') +} @asyncio.coroutine @@ -71,7 +78,7 @@ class HassIO(object): This method is a coroutine. """ try: - with async_timeout.timeout(TIMEOUT, loop=self.loop): + with async_timeout.timeout(10, loop=self.loop): request = yield from self.websession.get( "http://{}{}".format(self._ip, "/supervisor/ping") ) @@ -97,12 +104,12 @@ class HassIO(object): This method is a coroutine. """ - read_timeout = 0 if path in NO_TIMEOUT else 300 + read_timeout = _get_timeout(path) try: data = None headers = None - with async_timeout.timeout(TIMEOUT, loop=self.loop): + with async_timeout.timeout(10, loop=self.loop): data = yield from request.read() if data: headers = {CONTENT_TYPE: request.content_type} @@ -140,7 +147,7 @@ class HassIOView(HomeAssistantView): @asyncio.coroutine def _handle(self, request, path): """Route data to hassio.""" - if path != 'panel' and not request[KEY_AUTHENTICATED]: + if _need_auth(path) and not request[KEY_AUTHENTICATED]: return web.Response(status=401) client = yield from self.hassio.command_proxy(path, request) @@ -173,3 +180,19 @@ def _create_response_log(client, data): status=client.status, content_type=CONTENT_TYPE_TEXT_PLAIN, ) + + +def _get_timeout(path): + """Return timeout for a url path.""" + for re_path in NO_TIMEOUT: + if re_path.match(path): + return 0 + return 300 + + +def _need_auth(path): + """Return if a path need a auth.""" + for re_path in NO_AUTH: + if re_path.match(path): + return False + return True diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 9800a15c16b..893ff23df35 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -119,19 +119,42 @@ def get_states(hass, utc_point_in_time, entity_ids=None, run=None, from sqlalchemy import and_, func with session_scope(hass=hass) as session: - most_recent_state_ids = session.query( - func.max(States.state_id).label('max_state_id') - ).filter( - (States.created >= run.start) & - (States.created < utc_point_in_time) & - (~States.domain.in_(IGNORE_DOMAINS))) + if entity_ids and len(entity_ids) == 1: + # Use an entirely different (and extremely fast) query if we only + # have a single entity id + most_recent_state_ids = session.query( + States.state_id.label('max_state_id') + ).filter( + (States.created < utc_point_in_time) & + (States.entity_id.in_(entity_ids)) + ).order_by( + States.created.desc()) - if filters: - most_recent_state_ids = filters.apply(most_recent_state_ids, - entity_ids) + if filters: + most_recent_state_ids = filters.apply(most_recent_state_ids, + entity_ids) - most_recent_state_ids = most_recent_state_ids.group_by( - States.entity_id).subquery() + most_recent_state_ids = most_recent_state_ids.limit(1) + + else: + # We have more than one entity to look at (most commonly we want + # all entities,) so we need to do a search on all states since the + # last recorder run started. + most_recent_state_ids = session.query( + func.max(States.state_id).label('max_state_id') + ).filter( + (States.created >= run.start) & + (States.created < utc_point_in_time) & + (~States.domain.in_(IGNORE_DOMAINS))) + + if filters: + most_recent_state_ids = filters.apply(most_recent_state_ids, + entity_ids) + + most_recent_state_ids = most_recent_state_ids.group_by( + States.entity_id) + + most_recent_state_ids = most_recent_state_ids.subquery() query = session.query(States).join(most_recent_state_ids, and_( States.state_id == most_recent_state_ids.c.max_state_id)) diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py index a8fc645ee4c..f9583d9be7a 100644 --- a/homeassistant/components/homematic.py +++ b/homeassistant/components/homematic.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval from homeassistant.config import load_yaml_config_file -REQUIREMENTS = ['pyhomematic==0.1.29'] +REQUIREMENTS = ['pyhomematic==0.1.30'] DOMAIN = 'homematic' diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index fb1cddcad61..e6979087b6f 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -16,6 +16,7 @@ from homeassistant.config import load_yaml_config_file from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_ENTITY_ID) from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.loader import get_component @@ -59,6 +60,7 @@ SERVICE_SCAN_SCHEMA = vol.Schema({ }) +@bind_hass def scan(hass, entity_id=None): """Force process a image.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None diff --git a/homeassistant/components/image_processing/dlib_face_identify.py b/homeassistant/components/image_processing/dlib_face_identify.py index 8df6de91da4..ec98d5bdcff 100644 --- a/homeassistant/components/image_processing/dlib_face_identify.py +++ b/homeassistant/components/image_processing/dlib_face_identify.py @@ -58,8 +58,12 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): self._faces = {} for face_name, face_file in faces.items(): - image = face_recognition.load_image_file(face_file) - self._faces[face_name] = face_recognition.face_encodings(image)[0] + try: + image = face_recognition.load_image_file(face_file) + self._faces[face_name] = \ + face_recognition.face_encodings(image)[0] + except IndexError as err: + _LOGGER.error("Failed to parse %s. Error: %s", face_file, err) @property def camera_entity(self): diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index e975e42bcdc..3c4efdce175 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_TOGGLE, STATE_ON) +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -41,21 +42,25 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@bind_hass def is_on(hass, entity_id): """Test if input_boolean is True.""" return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def turn_on(hass, entity_id): """Set input_boolean to True.""" hass.services.call(DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) +@bind_hass def turn_off(hass, entity_id): """Set input_boolean to False.""" hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) +@bind_hass def toggle(hass, entity_id): """Set input_boolean to False.""" hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index c7f3a7f2236..f16b029c1d7 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -10,6 +10,7 @@ import logging import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -77,6 +78,7 @@ CONFIG_SCHEMA = vol.Schema({ }, required=True, extra=vol.ALLOW_EXTRA) +@bind_hass def select_option(hass, entity_id, option): """Set value of input_select.""" hass.services.call(DOMAIN, SERVICE_SELECT_OPTION, { @@ -85,6 +87,7 @@ def select_option(hass, entity_id, option): }) +@bind_hass def select_next(hass, entity_id): """Set next value of input_select.""" hass.services.call(DOMAIN, SERVICE_SELECT_NEXT, { @@ -92,6 +95,7 @@ def select_next(hass, entity_id): }) +@bind_hass def select_previous(hass, entity_id): """Set previous value of input_select.""" hass.services.call(DOMAIN, SERVICE_SELECT_PREVIOUS, { @@ -99,6 +103,7 @@ def select_previous(hass, entity_id): }) +@bind_hass def set_options(hass, entity_id, options): """Set options of input_select.""" hass.services.call(DOMAIN, SERVICE_SET_OPTIONS, { diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_slider.py index bd17376b2ef..5357878a0ce 100644 --- a/homeassistant/components/input_slider.py +++ b/homeassistant/components/input_slider.py @@ -12,6 +12,7 @@ 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 @@ -69,6 +70,7 @@ CONFIG_SCHEMA = vol.Schema({ }, required=True, extra=vol.ALLOW_EXTRA) +@bind_hass def select_value(hass, entity_id, value): """Set input_slider to value.""" hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, { diff --git a/homeassistant/components/intent_script.py b/homeassistant/components/intent_script.py new file mode 100644 index 00000000000..91489e188c5 --- /dev/null +++ b/homeassistant/components/intent_script.py @@ -0,0 +1,100 @@ +"""Handle intents with scripts.""" +import asyncio +import copy +import logging + +import voluptuous as vol + +from homeassistant.helpers import ( + intent, template, script, config_validation as cv) + +DOMAIN = 'intent_script' + +CONF_INTENTS = 'intents' +CONF_SPEECH = 'speech' + +CONF_ACTION = 'action' +CONF_CARD = 'card' +CONF_TYPE = 'type' +CONF_TITLE = 'title' +CONF_CONTENT = 'content' +CONF_TEXT = 'text' +CONF_ASYNC_ACTION = 'async_action' + +DEFAULT_CONF_ASYNC_ACTION = False + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + cv.string: { + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ASYNC_ACTION, + default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean, + vol.Optional(CONF_CARD): { + vol.Optional(CONF_TYPE, default='simple'): cv.string, + vol.Required(CONF_TITLE): cv.template, + vol.Required(CONF_CONTENT): cv.template, + }, + vol.Optional(CONF_SPEECH): { + vol.Optional(CONF_TYPE, default='plain'): cv.string, + vol.Required(CONF_TEXT): cv.template, + } + } + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup(hass, config): + """Activate Alexa component.""" + intents = copy.deepcopy(config[DOMAIN]) + template.attach(hass, intents) + + for intent_type, conf in intents.items(): + if CONF_ACTION in conf: + conf[CONF_ACTION] = script.Script( + hass, conf[CONF_ACTION], + "Intent Script {}".format(intent_type)) + intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) + + return True + + +class ScriptIntentHandler(intent.IntentHandler): + """Respond to an intent with a script.""" + + def __init__(self, intent_type, config): + """Initialize the script intent handler.""" + self.intent_type = intent_type + self.config = config + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + speech = self.config.get(CONF_SPEECH) + card = self.config.get(CONF_CARD) + action = self.config.get(CONF_ACTION) + is_async_action = self.config.get(CONF_ASYNC_ACTION) + slots = {key: value['value'] for key, value + in intent_obj.slots.items()} + + if action is not None: + if is_async_action: + intent_obj.hass.async_add_job(action.async_run(slots)) + else: + yield from action.async_run(slots) + + response = intent_obj.create_response() + + if speech is not None: + response.async_set_speech(speech[CONF_TEXT].async_render(slots), + speech[CONF_TYPE]) + + if card is not None: + response.async_set_card( + card[CONF_TITLE].async_render(slots), + card[CONF_CONTENT].async_render(slots), + card[CONF_TYPE]) + + return response diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index ec533a7850b..9530becb6ce 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/knx/ """ import logging +import os import voluptuous as vol @@ -12,8 +13,9 @@ 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 -REQUIREMENTS = ['knxip==0.4'] +REQUIREMENTS = ['knxip==0.5'] _LOGGER = logging.getLogger(__name__) @@ -25,6 +27,9 @@ EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received' EVENT_KNX_FRAME_SEND = 'knx_frame_send' KNXTUNNEL = None +KNX_ADDRESS = "address" +KNX_DATA = "data" +KNX_GROUP_WRITE = "group_write" CONF_LISTEN = "listen" CONFIG_SCHEMA = vol.Schema({ @@ -36,6 +41,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, 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]) +}) + def setup(hass, config): """Set up the connection to the KNX IP interface.""" @@ -65,6 +75,9 @@ def setup(hass, config): _LOGGER.info("KNX IP tunnel to %s:%i established", host, port) + 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: @@ -86,47 +99,37 @@ def setup(hass, config): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel) # Listen to KNX events and send them to the bus - def handle_knx_send(event): + def handle_group_write(call): """Bridge knx_frame_send events to the KNX bus.""" - try: - addr = event.data["address"] - except KeyError: - _LOGGER.error("KNX group address is missing") - return + # parameters are pre-validated using KNX_WRITE_SCHEMA + addrlist = call.data.get("address") + knxdata = call.data.get("data") - try: - data = event.data["data"] - except KeyError: - _LOGGER.error("KNX data block missing") - return - - knxaddr = None - try: - addr = int(addr) - except ValueError: - pass - - if knxaddr is None: + knxaddrlist = [] + for addr in addrlist: try: - knxaddr = parse_group_address(addr) - except KNXException: - _LOGGER.error("KNX address format incorrect") - return - - knxdata = None - if isinstance(data, list): - knxdata = data - else: - try: - knxdata = [int(data) & 0xff] + _LOGGER.debug("Found %s", addr) + knxaddr = int(addr) except ValueError: - _LOGGER.error("KNX data format incorrect") - return + knxaddr = None - KNXTUNNEL.group_write(knxaddr, knxdata) + 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.bus.listen(EVENT_KNX_FRAME_SEND, handle_knx_send) + hass.services.register(DOMAIN, + KNX_GROUP_WRITE, + handle_group_write, + descriptions[DOMAIN][KNX_GROUP_WRITE], + schema=KNX_WRITE_SCHEMA) return True diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 1dbd07f9439..4e9fbbf81ab 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -13,6 +13,7 @@ import csv import voluptuous as vol from homeassistant.core import callback +from homeassistant.loader import bind_hass from homeassistant.components import group from homeassistant.config import load_yaml_config_file from homeassistant.const import ( @@ -147,12 +148,14 @@ def extract_info(state): return params +@bind_hass def is_on(hass, entity_id=None): """Return if the lights are on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_LIGHTS return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def turn_on(hass, entity_id=None, transition=None, brightness=None, brightness_pct=None, rgb_color=None, xy_color=None, color_temp=None, kelvin=None, white_value=None, @@ -165,6 +168,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None, @callback +@bind_hass def async_turn_on(hass, entity_id=None, transition=None, brightness=None, brightness_pct=None, rgb_color=None, xy_color=None, color_temp=None, kelvin=None, white_value=None, @@ -191,12 +195,14 @@ def async_turn_on(hass, entity_id=None, transition=None, brightness=None, hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) +@bind_hass def turn_off(hass, entity_id=None, transition=None): """Turn all or specified light off.""" hass.add_job(async_turn_off, hass, entity_id, transition) @callback +@bind_hass def async_turn_off(hass, entity_id=None, transition=None): """Turn all or specified light off.""" data = { @@ -210,6 +216,7 @@ def async_turn_off(hass, entity_id=None, transition=None): DOMAIN, SERVICE_TURN_OFF, data)) +@bind_hass def toggle(hass, entity_id=None, transition=None): """Toggle all or specified light.""" data = { diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index bc45870f5f2..17cc741c593 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -16,7 +16,7 @@ from homeassistant.components.light import ( PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['decora==0.6'] +REQUIREMENTS = ['decora==0.6', 'bluepy==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -39,6 +39,7 @@ def retry(method): """Try send command and retry on error.""" # pylint: disable=import-error import decora + import bluepy initial = time.monotonic() while True: @@ -46,7 +47,10 @@ def retry(method): return None try: return method(device, *args, **kwds) - except (decora.decoraException, AttributeError): + except (decora.decoraException, AttributeError, + bluepy.btle.BTLEException): + _LOGGER.warning("Decora connect error for device %s. " + "Reconnecting...", device.name) # pylint: disable=protected-access device._switch.connect() return wrapper_retry @@ -119,7 +123,7 @@ class DecoraLight(Light): @retry def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - self._switch.set_brightness(brightness / 2.55) + self._switch.set_brightness(int(brightness / 2.55)) self._brightness = brightness @retry diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 3344de02e75..cdbea7d2194 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -115,7 +115,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is not None: if "HASS Bridge" in discovery_info.get('name', ''): - _LOGGER.info('Emulated hue found, will not add') + _LOGGER.info("Emulated hue found, will not add") return False host = discovery_info.get('host') @@ -126,7 +126,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): host = _find_host_from_config(hass, filename) if host is None: - _LOGGER.error('No host found in configuration') + _LOGGER.error("No host found in configuration") return False # Only act if we are not already configuring this host @@ -180,6 +180,12 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, try: api = bridge.get_api() + except phue.PhueRequestTimeout: + _LOGGER.warning("Timeout trying to reach the bridge") + return + except ConnectionRefusedError: + _LOGGER.error("The bridge refused the connection") + return except socket.error: # socket.error when we cannot reach Hue _LOGGER.exception("Cannot reach the bridge") @@ -221,8 +227,8 @@ def setup_bridge(host, hass, add_devices, filename, allow_unreachable, for lightgroup_id, info in api_groups.items(): if 'state' not in info: - _LOGGER.warning('Group info does not contain state. ' - 'Please update your hub.') + _LOGGER.warning("Group info does not contain state. " + "Please update your hub.") skip_groups = True break diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index a6c5f855875..908a9d24e04 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -33,7 +33,7 @@ import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.5.2', 'aiolifx_effects==0.1.1'] +REQUIREMENTS = ['aiolifx==0.5.4', 'aiolifx_effects==0.1.1'] UDP_BROADCAST_PORT = 56700 diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py new file mode 100644 index 00000000000..333661870d1 --- /dev/null +++ b/homeassistant/components/light/tplink.py @@ -0,0 +1,116 @@ +""" +Support for TPLink lights. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light.tplink/ +""" +import logging +from homeassistant.const import (CONF_HOST, CONF_NAME) +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_KELVIN, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP) +from homeassistant.util.color import \ + color_temperature_mired_to_kelvin as mired_to_kelvin +from homeassistant.util.color import \ + color_temperature_kelvin_to_mired as kelvin_to_mired + +REQUIREMENTS = ['pyHS100==0.2.4.2'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_TPLINK = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Initialise pyLB100 SmartBulb.""" + from pyHS100 import SmartBulb + host = config.get(CONF_HOST) + name = config.get(CONF_NAME) + add_devices([TPLinkSmartBulb(SmartBulb(host), name)], True) + + +def brightness_to_percentage(byt): + """Convert brightness from absolute 0..255 to percentage.""" + return (byt*100.0)/255.0 + + +def brightness_from_percentage(percent): + """Convert percentage to absolute value 0..255.""" + return (percent*255.0)/100.0 + + +class TPLinkSmartBulb(Light): + """Representation of a TPLink Smart Bulb.""" + + def __init__(self, smartbulb, name): + """Initialize the bulb.""" + self.smartbulb = smartbulb + + # Use the name set on the device if not set + if name is None: + self._name = self.smartbulb.alias + else: + self._name = name + + self._state = None + _LOGGER.debug("Setting up TP-Link Smart Bulb") + + @property + def name(self): + """Return the name of the Smart Bulb, if any.""" + return self._name + + def turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_COLOR_TEMP in kwargs: + self.smartbulb.color_temp = \ + mired_to_kelvin(kwargs[ATTR_COLOR_TEMP]) + if ATTR_KELVIN in kwargs: + self.smartbulb.color_temp = kwargs[ATTR_KELVIN] + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255) + self.smartbulb.brightness = brightness_to_percentage(brightness) + + self.smartbulb.state = self.smartbulb.BULB_STATE_ON + + def turn_off(self): + """Turn the light off.""" + self.smartbulb.state = self.smartbulb.BULB_STATE_OFF + + @property + def color_temp(self): + """Return the color temperature of this light in mireds for HA.""" + if self.smartbulb.is_color: + if (self.smartbulb.color_temp is not None and + self.smartbulb.color_temp != 0): + return kelvin_to_mired(self.smartbulb.color_temp) + else: + return None + else: + return None + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return brightness_from_percentage(self.smartbulb.brightness) + + @property + def is_on(self): + """True if device is on.""" + return self.smartbulb.state == \ + self.smartbulb.BULB_STATE_ON + + def update(self): + """Update the TP-Link Bulb's state.""" + from pyHS100 import SmartPlugException + try: + self._state = self.smartbulb.state == \ + self.smartbulb.BULB_STATE_ON + + except (SmartPlugException, OSError) as ex: + _LOGGER.warning('Could not read state for %s: %s', self.name, ex) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_TPLINK diff --git a/homeassistant/components/light/velbus.py b/homeassistant/components/light/velbus.py new file mode 100644 index 00000000000..8a02b36b75f --- /dev/null +++ b/homeassistant/components/light/velbus.py @@ -0,0 +1,104 @@ +""" +Support for Velbus lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.velbus/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_DEVICES +from homeassistant.components.light import Light, PLATFORM_SCHEMA +from homeassistant.components.velbus import DOMAIN +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['velbus'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ + { + vol.Required('module'): cv.positive_int, + vol.Required('channel'): cv.positive_int, + vol.Required(CONF_NAME): cv.string + } + ]) +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Lights.""" + velbus = hass.data[DOMAIN] + add_devices(VelbusLight(light, velbus) for light in config[CONF_DEVICES]) + + +class VelbusLight(Light): + """Representation of a Velbus Light.""" + + def __init__(self, light, velbus): + """Initialize a Velbus light.""" + self._velbus = velbus + self._name = light[CONF_NAME] + self._module = light['module'] + self._channel = light['channel'] + self._state = False + + @asyncio.coroutine + def async_added_to_hass(self): + """Add listener for Velbus messages on bus.""" + def _init_velbus(): + """Initialize Velbus on startup.""" + self._velbus.subscribe(self._on_message) + self.get_status() + + yield from self.hass.async_add_job(_init_velbus) + + def _on_message(self, message): + import velbus + if isinstance(message, velbus.RelayStatusMessage) and \ + message.address == self._module and \ + message.channel == self._channel: + self._state = message.is_on() + self.schedule_update_ha_state() + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def is_on(self): + """Return true if the light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import velbus + message = velbus.SwitchRelayOnMessage() + message.set_defaults(self._module) + message.relay_channels = [self._channel] + self._velbus.send(message) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + import velbus + message = velbus.SwitchRelayOffMessage() + message.set_defaults(self._module) + message.relay_channels = [self._channel] + self._velbus.send(message) + + def get_status(self): + """Retrieve current status.""" + import velbus + message = velbus.ModuleStatusRequestMessage() + message.set_defaults(self._module) + message.channels = [self._channel] + self._velbus.send(message) diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 93b23a9d7ba..445fe8ceb25 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -28,6 +28,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _id = light.object_id() + light.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkLight(light, hass)]) + for light in pywink.get_light_groups(): + _id = light.object_id() + light.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkLight(light, hass)]) class WinkLight(WinkDevice, Light): @@ -101,7 +105,7 @@ class WinkLight(WinkDevice, Light): xyb = color_util.color_RGB_to_xy(*rgb_color) state_kwargs['color_xy'] = xyb[0], xyb[1] state_kwargs['brightness'] = xyb[2] - elif self.wink.supports_hue_saturation(): + if self.wink.supports_hue_saturation(): hsv = colorsys.rgb_to_hsv( rgb_color[0], rgb_color[1], rgb_color[2]) state_kwargs['color_hue_saturation'] = hsv[0], hsv[1] diff --git a/homeassistant/components/light/xiaomi.py b/homeassistant/components/light/xiaomi.py new file mode 100755 index 00000000000..d8a70b726f4 --- /dev/null +++ b/homeassistant/components/light/xiaomi.py @@ -0,0 +1,103 @@ +"""Support for Xiaomi Gateway Light.""" +import logging +import struct +import binascii +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR, + SUPPORT_BRIGHTNESS, + SUPPORT_RGB_COLOR, Light) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['light']: + model = device['model'] + if model == 'gateway': + devices.append(XiaomiGatewayLight(device, 'Gateway Light', + gateway)) + add_devices(devices) + + +class XiaomiGatewayLight(XiaomiDevice, Light): + """Representation of a XiaomiGatewayLight.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the XiaomiGatewayLight.""" + self._data_key = 'rgb' + self._rgb = (255, 255, 255) + self._brightness = 180 + + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def is_on(self): + """Return true if it is on.""" + return self._state + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + + if value == 0: + if self._state: + self._state = False + return True + + rgbhexstr = "%x" % value + if len(rgbhexstr) == 7: + rgbhexstr = '0' + rgbhexstr + elif len(rgbhexstr) != 8: + _LOGGER.error('Light RGB data error.' + ' Must be 8 characters. Received: %s', rgbhexstr) + return False + + rgbhex = bytes.fromhex(rgbhexstr) + rgba = struct.unpack('BBBB', rgbhex) + brightness = rgba[0] + rgb = rgba[1:] + + self._brightness = int(255 * brightness / 100) + self._rgb = rgb + self._state = True + return True + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def rgb_color(self): + """Return the RBG color value.""" + return self._rgb + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR + + def turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_RGB_COLOR in kwargs: + self._rgb = kwargs[ATTR_RGB_COLOR] + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = int(100 * kwargs[ATTR_BRIGHTNESS] / 255) + + rgba = (self._brightness,) + self._rgb + rgbhex = binascii.hexlify(struct.pack('BBBB', *rgba)).decode("ASCII") + rgbhex = int(rgbhex, 16) + + if self._write_to_hub(self._sid, **{self._data_key: rgbhex}): + self._state = True + + def turn_off(self, **kwargs): + """Turn the light off.""" + if self._write_to_hub(self._sid, **{self._data_key: 0}): + self._state = False diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 619723d3168..2a3ce18d74e 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -9,11 +9,14 @@ import logging from homeassistant.components import light, zha from homeassistant.util.color import color_RGB_to_xy +from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zha'] +DEFAULT_DURATION = 0.5 + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -48,6 +51,7 @@ class Light(zha.Entity, light.Light): import bellows.zigbee.zcl.clusters as zcl_clusters if zcl_clusters.general.LevelControl.cluster_id in self._in_clusters: self._supported_features |= light.SUPPORT_BRIGHTNESS + 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 @@ -62,14 +66,15 @@ class Light(zha.Entity, light.Light): @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._state == 'unknown': + if self._state == STATE_UNKNOWN: return False return bool(self._state) @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the entity on.""" - duration = 5 # tenths of s + duration = kwargs.get(light.ATTR_TRANSITION, DEFAULT_DURATION) + duration = duration * 10 # tenths of s if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] yield from self._endpoint.light_color.move_to_color_temp( @@ -91,7 +96,8 @@ class Light(zha.Entity, light.Light): ) if self._brightness is not None: - brightness = kwargs.get('brightness', self._brightness or 255) + brightness = kwargs.get( + light.ATTR_BRIGHTNESS, self._brightness or 255) self._brightness = brightness # Move to level with on/off: yield from self._endpoint.level.move_to_level_with_on_off( diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 369f0c93b85..c64f77b3bd6 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -13,6 +13,7 @@ import os import voluptuous as vol from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import Entity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -43,12 +44,14 @@ LOCK_SERVICE_SCHEMA = vol.Schema({ _LOGGER = logging.getLogger(__name__) +@bind_hass def is_locked(hass, entity_id=None): """Return if the lock is locked based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_LOCKS return hass.states.is_state(entity_id, STATE_LOCKED) +@bind_hass def lock(hass, entity_id=None, code=None): """Lock all or specified locks.""" data = {} @@ -60,6 +63,7 @@ def lock(hass, entity_id=None, code=None): hass.services.call(DOMAIN, SERVICE_LOCK, data) +@bind_hass def unlock(hass, entity_id=None, code=None): """Unlock all or specified locks.""" data = {} diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index c7d019973a3..cb00c8fe035 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -6,19 +6,34 @@ https://home-assistant.io/components/media_extractor/ """ import logging import os +import voluptuous as vol from homeassistant.components.media_player import ( - ATTR_MEDIA_CONTENT_ID, DOMAIN as MEDIA_PLAYER_DOMAIN, - MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, SERVICE_PLAY_MEDIA) + ATTR_ENTITY_ID, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + DOMAIN as MEDIA_PLAYER_DOMAIN, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA, + SERVICE_PLAY_MEDIA) from homeassistant.config import load_yaml_config_file +from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2017.7.9'] +REQUIREMENTS = ['youtube_dl==2017.7.23'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'media_extractor' DEPENDENCIES = ['media_player'] +CONF_CUSTOMIZE_ENTITIES = 'customize' +CONF_DEFAULT_STREAM_QUERY = 'default_query' +DEFAULT_STREAM_QUERY = 'best' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string, + vol.Optional(CONF_CUSTOMIZE_ENTITIES): + vol.Schema({cv.entity_id: vol.Schema({cv.string: cv.string})}), + }), +}, extra=vol.ALLOW_EXTRA) + def setup(hass, config): """Set up the media extractor service.""" @@ -28,23 +43,7 @@ def setup(hass, config): def play_media(call): """Get stream URL and send it to the media_player.play_media.""" - media_url = call.data.get(ATTR_MEDIA_CONTENT_ID) - - try: - stream_url = get_media_stream_url(media_url) - except YDException: - _LOGGER.error("Could not retrieve data for the URL: %s", - media_url) - return - else: - data = {k: v for k, v in call.data.items() - if k != ATTR_MEDIA_CONTENT_ID} - data[ATTR_MEDIA_CONTENT_ID] = stream_url - - hass.async_add_job( - hass.services.async_call( - MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) - ) + MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send() hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, @@ -55,47 +54,136 @@ def setup(hass, config): return True -class YDException(Exception): - """General service exception.""" +class MEDownloadException(Exception): + """Media extractor download exception.""" pass -def get_media_stream_url(media_url): - """Extract stream URL from the media URL.""" - from youtube_dl import YoutubeDL - from youtube_dl.utils import DownloadError, ExtractorError +class MEQueryException(Exception): + """Media extractor query exception.""" - ydl = YoutubeDL({'quiet': True, 'logger': _LOGGER}) + pass - try: - all_media_streams = ydl.extract_info(media_url, process=False) - except DownloadError: - # This exception will be logged by youtube-dl itself - raise YDException() - if 'entries' in all_media_streams: - _LOGGER.warning("Playlists are not supported, " - "looking for the first video") +class MediaExtractor: + """Class which encapsulates all extraction logic.""" + + def __init__(self, hass, component_config, call_data): + """Initialize media extractor.""" + self.hass = hass + self.config = component_config + self.call_data = call_data + + def get_media_url(self): + """Return media content url.""" + return self.call_data.get(ATTR_MEDIA_CONTENT_ID) + + def get_entities(self): + """Return list of entities.""" + return self.call_data.get(ATTR_ENTITY_ID, []) + + def extract_and_send(self): + """Extract exact stream format for each entity_id and play it.""" try: - selected_stream = next(all_media_streams['entries']) - except StopIteration: - _LOGGER.error("Playlist is empty") - raise YDException() - else: - selected_stream = all_media_streams + stream_selector = self.get_stream_selector() + except MEDownloadException: + _LOGGER.error("Could not retrieve data for the URL: %s", + self.get_media_url()) + else: + entities = self.get_entities() - try: - media_info = ydl.process_ie_result(selected_stream, download=False) - except (ExtractorError, DownloadError): - # This exception will be logged by youtube-dl itself - raise YDException() + if len(entities) == 0: + self.call_media_player_service(stream_selector, None) - format_selector = ydl.build_format_selector('best') + for entity_id in entities: + self.call_media_player_service(stream_selector, entity_id) - try: - best_quality_stream = next(format_selector(media_info)) - except (KeyError, StopIteration): - best_quality_stream = media_info + def get_stream_selector(self): + """Return format selector for the media URL.""" + from youtube_dl import YoutubeDL + from youtube_dl.utils import DownloadError, ExtractorError - return best_quality_stream['url'] + ydl = YoutubeDL({'quiet': True, 'logger': _LOGGER}) + + try: + all_media = ydl.extract_info(self.get_media_url(), + process=False) + except DownloadError: + # This exception will be logged by youtube-dl itself + raise MEDownloadException() + + if 'entries' in all_media: + _LOGGER.warning("Playlists are not supported, " + "looking for the first video") + entries = list(all_media['entries']) + if len(entries) > 0: + selected_media = entries[0] + else: + _LOGGER.error("Playlist is empty") + raise MEDownloadException() + else: + selected_media = all_media + + try: + media_info = ydl.process_ie_result(selected_media, + download=False) + except (ExtractorError, DownloadError): + # This exception will be logged by youtube-dl itself + raise MEDownloadException() + + def stream_selector(query): + """Find stream url that matches query.""" + try: + format_selector = ydl.build_format_selector(query) + except (SyntaxError, ValueError, AttributeError) as ex: + _LOGGER.error(ex) + raise MEQueryException() + + try: + requested_stream = next(format_selector(media_info)) + except (KeyError, StopIteration): + _LOGGER.error("Could not extract stream for the query: %s", + query) + raise MEQueryException() + + return requested_stream['url'] + + return stream_selector + + def call_media_player_service(self, stream_selector, entity_id): + """Call media_player.play_media service.""" + stream_query = self.get_stream_query_for_entity(entity_id) + + try: + stream_url = stream_selector(stream_query) + except MEQueryException: + _LOGGER.error("Wrong query format: %s", stream_query) + return + else: + data = {k: v for k, v in self.call_data.items() + if k != ATTR_ENTITY_ID} + data[ATTR_MEDIA_CONTENT_ID] = stream_url + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + self.hass.async_add_job( + self.hass.services.async_call( + MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data) + ) + + def get_stream_query_for_entity(self, entity_id): + """Get stream format query for entity.""" + default_stream_query = self.config.get(CONF_DEFAULT_STREAM_QUERY, + DEFAULT_STREAM_QUERY) + + if entity_id: + media_content_type = self.call_data.get(ATTR_MEDIA_CONTENT_TYPE) + + return self.config \ + .get(CONF_CUSTOMIZE_ENTITIES, {}) \ + .get(entity_id, {}) \ + .get(media_content_type, default_stream_query) + + return default_stream_query diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 35981d89d6d..a53f7f1367a 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -17,6 +17,7 @@ import async_timeout import voluptuous as vol from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -199,6 +200,7 @@ ATTR_TO_PROPERTY = [ ] +@bind_hass def is_on(hass, entity_id=None): """ Return true if specified media player entity_id is on. @@ -210,36 +212,42 @@ def is_on(hass, entity_id=None): for entity_id in entity_ids) +@bind_hass def turn_on(hass, entity_id=None): """Turn on specified media player or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TURN_ON, data) +@bind_hass def turn_off(hass, entity_id=None): """Turn off specified media player or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) +@bind_hass def toggle(hass, entity_id=None): """Toggle specified media player or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_TOGGLE, data) +@bind_hass def volume_up(hass, entity_id=None): """Send the media player the command for volume up.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data) +@bind_hass def volume_down(hass, entity_id=None): """Send the media player the command for volume down.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data) +@bind_hass def mute_volume(hass, mute, entity_id=None): """Send the media player the command for muting the volume.""" data = {ATTR_MEDIA_VOLUME_MUTED: mute} @@ -250,6 +258,7 @@ def mute_volume(hass, mute, entity_id=None): hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, data) +@bind_hass def set_volume_level(hass, volume, entity_id=None): """Send the media player the command for setting the volume.""" data = {ATTR_MEDIA_VOLUME_LEVEL: volume} @@ -260,42 +269,49 @@ def set_volume_level(hass, volume, entity_id=None): hass.services.call(DOMAIN, SERVICE_VOLUME_SET, data) +@bind_hass def media_play_pause(hass, entity_id=None): """Send the media player the command for play/pause.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data) +@bind_hass def media_play(hass, entity_id=None): """Send the media player the command for play/pause.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data) +@bind_hass def media_pause(hass, entity_id=None): """Send the media player the command for pause.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data) +@bind_hass def media_stop(hass, entity_id=None): """Send the media player the stop command.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_STOP, data) +@bind_hass def media_next_track(hass, entity_id=None): """Send the media player the command for next track.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) +@bind_hass def media_previous_track(hass, entity_id=None): """Send the media player the command for prev track.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) +@bind_hass def media_seek(hass, position, entity_id=None): """Send the media player the command to seek in current playing media.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -303,6 +319,7 @@ def media_seek(hass, position, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data) +@bind_hass def play_media(hass, media_type, media_id, entity_id=None, enqueue=None): """Send the media player the command for playing media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, @@ -317,6 +334,7 @@ def play_media(hass, media_type, media_id, entity_id=None, enqueue=None): hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data) +@bind_hass def select_source(hass, source, entity_id=None): """Send the media player the command to select input source.""" data = {ATTR_INPUT_SOURCE: source} @@ -327,12 +345,14 @@ def select_source(hass, source, entity_id=None): hass.services.call(DOMAIN, SERVICE_SELECT_SOURCE, data) +@bind_hass def clear_playlist(hass, entity_id=None): """Send the media player the command for clear playlist.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} hass.services.call(DOMAIN, SERVICE_CLEAR_PLAYLIST, data) +@bind_hass def set_shuffle(hass, shuffle, entity_id=None): """Send the media player the command to enable/disable shuffle mode.""" data = {ATTR_MEDIA_SHUFFLE: shuffle} diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 51acf68d819..c416157169e 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -20,7 +20,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==0.8.1'] +REQUIREMENTS = ['pychromecast==0.8.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index f484c04a058..3764677c847 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.kodi/ """ import asyncio +from collections import OrderedDict from functools import wraps import logging import urllib @@ -20,15 +21,18 @@ from homeassistant.components.media_player import ( SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_PLAY, SUPPORT_VOLUME_STEP, SUPPORT_SHUFFLE_SET, MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, - MEDIA_TYPE_VIDEO, MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN) + MEDIA_TYPE_VIDEO, MEDIA_TYPE_PLAYLIST, MEDIA_PLAYER_SCHEMA, DOMAIN, + SUPPORT_TURN_ON) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, CONF_PROXY_SSL, CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import script, config_validation as cv from homeassistant.helpers.deprecation import get_deprecated +from homeassistant.helpers.template import Template +from homeassistant.util.yaml import dump REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.5'] @@ -37,6 +41,7 @@ _LOGGER = logging.getLogger(__name__) EVENT_KODI_CALL_METHOD_RESULT = 'kodi_call_method_result' CONF_TCP_PORT = 'tcp_port' +CONF_TURN_ON_ACTION = 'turn_on_action' CONF_TURN_OFF_ACTION = 'turn_off_action' CONF_ENABLE_WEBSOCKET = 'enable_websocket' @@ -47,7 +52,14 @@ DEFAULT_TIMEOUT = 5 DEFAULT_PROXY_SSL = False DEFAULT_ENABLE_WEBSOCKET = True -TURN_OFF_ACTION = [None, 'quit', 'hibernate', 'suspend', 'reboot', 'shutdown'] +DEPRECATED_TURN_OFF_ACTIONS = { + None: None, + 'quit': 'Application.Quit', + 'hibernate': 'System.Hibernate', + 'suspend': 'System.Suspend', + 'reboot': 'System.Reboot', + 'shutdown': 'System.Shutdown' +} # https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h MEDIA_TYPES = { @@ -75,7 +87,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean, - vol.Optional(CONF_TURN_OFF_ACTION, default=None): vol.In(TURN_OFF_ACTION), + vol.Optional(CONF_TURN_ON_ACTION, default=None): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF_ACTION): + vol.Any(cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)), vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Inclusive(CONF_USERNAME, 'auth'): cv.string, vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string, @@ -114,11 +128,36 @@ SERVICE_TO_METHOD = { } +def _check_deprecated_turn_off(hass, turn_off_action): + """Create an equivalent script for old turn off actions.""" + if isinstance(turn_off_action, str): + method = DEPRECATED_TURN_OFF_ACTIONS[turn_off_action] + new_config = OrderedDict( + [('service', '{}.{}'.format(DOMAIN, SERVICE_CALL_METHOD)), + ('data_template', OrderedDict( + [('entity_id', '{{ entity_id }}'), + ('method', method)]))]) + example_conf = dump(OrderedDict( + [(CONF_TURN_OFF_ACTION, new_config)])) + _LOGGER.warning( + "The '%s' action for turn off Kodi is deprecated and " + "will cease to function in a future release. You need to " + "change it for a generic Home Assistant script sequence, " + "which is, for this turn_off action, like this:\n%s", + turn_off_action, example_conf) + new_config['data_template'] = OrderedDict( + [(key, Template(value, hass)) + for key, value in new_config['data_template'].items()]) + turn_off_action = [new_config] + return turn_off_action + + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Kodi platform.""" if DATA_KODI not in hass.data: hass.data[DATA_KODI] = [] + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) tcp_port = config.get(CONF_TCP_PORT) @@ -134,10 +173,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity = KodiDevice( hass, - name=config.get(CONF_NAME), + name=name, host=host, port=port, tcp_port=tcp_port, encryption=encryption, username=config.get(CONF_USERNAME), password=config.get(CONF_PASSWORD), + turn_on_action=config.get(CONF_TURN_ON_ACTION), turn_off_action=config.get(CONF_TURN_OFF_ACTION), timeout=config.get(CONF_TIMEOUT), websocket=websocket) @@ -210,7 +250,8 @@ class KodiDevice(MediaPlayerDevice): """Representation of a XBMC/Kodi device.""" def __init__(self, hass, name, host, port, tcp_port, encryption=False, - username=None, password=None, turn_off_action=None, + username=None, password=None, + turn_on_action=None, turn_off_action=None, timeout=DEFAULT_TIMEOUT, websocket=True): """Initialize the Kodi device.""" import jsonrpc_async @@ -262,6 +303,17 @@ class KodiDevice(MediaPlayerDevice): else: self._ws_server = None + # Script creation for the turn on/off config options + if turn_on_action is not None: + turn_on_action = script.Script( + self.hass, turn_on_action, + "{} turn ON script".format(self.name), + self.async_update_ha_state(True)) + if turn_off_action is not None: + turn_off_action = script.Script( + self.hass, _check_deprecated_turn_off(hass, turn_off_action), + "{} turn OFF script".format(self.name)) + self._turn_on_action = turn_on_action self._turn_off_action = turn_off_action self._enable_websocket = websocket self._players = list() @@ -304,7 +356,7 @@ class KodiDevice(MediaPlayerDevice): @callback def async_on_quit(self, sender, data): - """Handle the muted volume.""" + """Reset the player state on quit action.""" self._players = None self._properties = {} self._item = {} @@ -520,25 +572,31 @@ class KodiDevice(MediaPlayerDevice): """Flag media player features that are supported.""" supported_features = SUPPORT_KODI - if self._turn_off_action in TURN_OFF_ACTION: + if self._turn_on_action is not None: + supported_features |= SUPPORT_TURN_ON + + if self._turn_off_action is not None: supported_features |= SUPPORT_TURN_OFF return supported_features + @cmd + @asyncio.coroutine + def async_turn_on(self): + """Execute turn_on_action to turn on media player.""" + if self._turn_on_action is not None: + yield from self._turn_on_action.async_run( + variables={"entity_id": self.entity_id}) + else: + _LOGGER.warning("turn_on requested but turn_on_action is none") + @cmd @asyncio.coroutine def async_turn_off(self): """Execute turn_off_action to turn off media player.""" - if self._turn_off_action == 'quit': - yield from self.server.Application.Quit() - elif self._turn_off_action == 'hibernate': - yield from self.server.System.Hibernate() - elif self._turn_off_action == 'suspend': - yield from self.server.System.Suspend() - elif self._turn_off_action == 'reboot': - yield from self.server.System.Reboot() - elif self._turn_off_action == 'shutdown': - yield from self.server.System.Shutdown() + if self._turn_off_action is not None: + yield from self._turn_off_action.async_run( + variables={"entity_id": self.entity_id}) else: _LOGGER.warning("turn_off requested but turn_off_action is none") diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index aac6b1a228d..5917f1e3083 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -15,7 +15,6 @@ from homeassistant.components.media_player import ( from homeassistant.const import ( CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME) import homeassistant.helpers.config_validation as cv -import homeassistant.loader as loader REQUIREMENTS = ['python-roku==3.1.3'] @@ -52,7 +51,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elif CONF_HOST in config: hosts.append(config.get(CONF_HOST)) - persistent_notification = loader.get_component('persistent_notification') rokus = [] for host in hosts: new_roku = RokuDevice(host) @@ -66,8 +64,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): except AttributeError: _LOGGER.error("Unable to initialize roku at %s", host) - persistent_notification.create( - hass, 'Error: Unable to initialize roku at {}
' + hass.components.persistent_notification.create( + 'Error: Unable to initialize roku at {}
' 'Check its network connection or consider ' 'using auto discovery.
' 'You will need to restart hass after fixing.' diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index f5a66412962..929ae0fc455 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -19,6 +19,7 @@ from homeassistant.core import callback from homeassistant.setup import async_prepare_setup_platform from homeassistant.config import load_yaml_config_file from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass from homeassistant.helpers import template, config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, dispatcher_send) @@ -57,6 +58,7 @@ CONF_WILL_MESSAGE = 'will_message' CONF_STATE_TOPIC = 'state_topic' CONF_COMMAND_TOPIC = 'command_topic' +CONF_AVAILABILITY_TOPIC = 'availability_topic' CONF_QOS = 'qos' CONF_RETAIN = 'retain' @@ -180,12 +182,14 @@ def _build_publish_data(topic, qos, retain): return data +@bind_hass def publish(hass, topic, payload, qos=None, retain=None): """Publish message to an MQTT topic.""" hass.add_job(async_publish, hass, topic, payload, qos, retain) @callback +@bind_hass def async_publish(hass, topic, payload, qos=None, retain=None): """Publish message to an MQTT topic.""" data = _build_publish_data(topic, qos, retain) @@ -193,6 +197,7 @@ def async_publish(hass, topic, payload, qos=None, retain=None): hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_PUBLISH, data)) +@bind_hass def publish_template(hass, topic, payload_template, qos=None, retain=None): """Publish message to an MQTT topic using a template payload.""" data = _build_publish_data(topic, qos, retain) @@ -201,6 +206,7 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None): @asyncio.coroutine +@bind_hass def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, encoding='utf-8'): """Subscribe to an MQTT topic.""" @@ -232,6 +238,7 @@ def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, return async_remove +@bind_hass def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, encoding='utf-8'): """Subscribe to an MQTT topic.""" diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index f9f9d04c05c..1c17d1a795a 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.config import load_yaml_config_file from homeassistant.const import CONF_NAME, CONF_PLATFORM @@ -51,6 +52,7 @@ NOTIFY_SERVICE_SCHEMA = vol.Schema({ }) +@bind_hass def send_message(hass, message, title=None, data=None): """Send a notification message.""" info = { diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 1fcd2e03898..7151b418248 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -25,7 +25,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.frontend import add_manifest_json_key from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pywebpush==1.0.5', 'PyJWT==1.5.0'] +REQUIREMENTS = ['pywebpush==1.0.6', 'PyJWT==1.5.2'] DEPENDENCIES = ['frontend'] diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 6d74f86132a..9d2a8c07932 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -16,7 +16,7 @@ from homeassistant.components.notify import ( ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME -REQUIREMENTS = ['TwitterAPI==2.4.5'] +REQUIREMENTS = ['TwitterAPI==2.4.6'] _LOGGER = logging.getLogger(__name__) @@ -60,10 +60,13 @@ class TwitterNotificationService(BaseNotificationService): def send_message(self, message="", **kwargs): """Tweet a message, optionally with media.""" data = kwargs.get(ATTR_DATA) - media = data.get(ATTR_MEDIA) - if not self.hass.config.is_allowed_path(media): - _LOGGER.warning("'%s' is not in a whitelisted area.", media) - return + + media = None + if data: + media = data.get(ATTR_MEDIA) + if not self.hass.config.is_allowed_path(media): + _LOGGER.warning("'%s' is not a whitelisted directory", media) + return media_id = self.upload_media(media) @@ -94,8 +97,7 @@ class TwitterNotificationService(BaseNotificationService): return None media_id = resp.json()['media_id'] - media_id = self.upload_media_chunked(file, total_bytes, - 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: @@ -147,8 +149,8 @@ class TwitterNotificationService(BaseNotificationService): def log_error_resp(resp): """Log error response.""" obj = json.loads(resp.text) - error_message = obj['error'] - _LOGGER.error("Error %s : %s", resp.status_code, error_message) + error_message = obj['errors'] + _LOGGER.error("Error %s: %s", resp.status_code, error_message) @staticmethod def log_error_resp_append(resp): @@ -156,5 +158,5 @@ class TwitterNotificationService(BaseNotificationService): obj = json.loads(resp.text) error_message = obj['errors'][0]['message'] error_code = obj['errors'][0]['code'] - _LOGGER.error("Error %s : %s (Code %s)", resp.status_code, + _LOGGER.error("Error %s: %s (Code %s)", resp.status_code, error_message, error_code) diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py index 212b2e7e7da..5e68aeee3ab 100644 --- a/homeassistant/components/persistent_notification.py +++ b/homeassistant/components/persistent_notification.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.exceptions import TemplateError +from homeassistant.loader import bind_hass from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.util import slugify @@ -43,17 +44,20 @@ DEFAULT_OBJECT_ID = 'notification' _LOGGER = logging.getLogger(__name__) +@bind_hass def create(hass, message, title=None, notification_id=None): """Generate a notification.""" hass.add_job(async_create, hass, message, title, notification_id) +@bind_hass def dismiss(hass, notification_id): """Remove a notification.""" hass.add_job(async_dismiss, hass, notification_id) @callback +@bind_hass def async_create(hass, message, title=None, notification_id=None): """Generate a notification.""" data = { @@ -68,6 +72,7 @@ def async_create(hass, message, title=None, notification_id=None): @callback +@bind_hass def async_dismiss(hass, notification_id): """Remove a notification.""" data = {ATTR_NOTIFICATION_ID: notification_id} diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index c159bec0f75..386abba59ae 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -6,6 +6,7 @@ import logging import voluptuous as vol from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename DOMAIN = 'python_script' @@ -49,6 +50,7 @@ def setup(hass, config): return True +@bind_hass def execute_script(hass, name, data=None): """Execute a script.""" filename = '{}.py'.format(name) @@ -57,11 +59,13 @@ def execute_script(hass, name, data=None): execute(hass, filename, source, data) +@bind_hass def execute(hass, filename, source, data=None): """Execute Python source.""" from RestrictedPython import compile_restricted_exec from RestrictedPython.Guards import safe_builtins, full_write_guard from RestrictedPython.Utilities import utility_builtins + from RestrictedPython.Eval import default_guarded_getitem compiled = compile_restricted_exec(source, filename=filename) @@ -96,6 +100,7 @@ def execute(hass, filename, source, data=None): '_getattr_': protected_getattr, '_write_': full_write_guard, '_getiter_': iter, + '_getitem_': default_guarded_getitem } logger = logging.getLogger('{}.{}'.format(__name__, filename)) local = { diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 63dbf9fc1b1..77a049376e5 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -33,7 +33,7 @@ from . import purge, migration from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.1.11'] +REQUIREMENTS = ['sqlalchemy==1.1.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 4e00a053cf9..bec4adcaa7e 100755 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -13,6 +13,7 @@ import os import voluptuous as vol from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv @@ -62,12 +63,14 @@ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ }) +@bind_hass def is_on(hass, entity_id=None): """Return if the remote is on based on the statemachine.""" entity_id = entity_id or ENTITY_ID_ALL_REMOTES return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def turn_on(hass, activity=None, entity_id=None): """Turn all or specified remote on.""" data = {ATTR_ACTIVITY: activity} @@ -76,12 +79,14 @@ def turn_on(hass, activity=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_TURN_ON, data) +@bind_hass def turn_off(hass, entity_id=None): """Turn all or specified remote off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.services.call(DOMAIN, SERVICE_TURN_OFF, data) +@bind_hass def send_command(hass, device, command, entity_id=None, num_repeats=None, delay_secs=None): """Send a command to a device.""" diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 165bd0b9114..cdc6a7ac61f 100755 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -174,7 +174,7 @@ class HarmonyRemote(remote.RemoteDevice): @property def is_on(self): """Return False if PowerOff is the current activity, otherwise True.""" - return self._current_activity != 'PowerOff' + return self._current_activity not in [None, 'PowerOff'] def update(self): """Return current activity.""" diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 450ef6b1978..a1529fddbd6 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -9,7 +9,6 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -import homeassistant.loader as loader from requests.exceptions import HTTPError, ConnectTimeout @@ -40,7 +39,6 @@ def setup(hass, config): username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) - persistent_notification = loader.get_component('persistent_notification') try: from ring_doorbell import Ring @@ -51,8 +49,8 @@ def setup(hass, config): hass.data['ring'] = ring except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Ring service: %s", str(ex)) - persistent_notification.create( - hass, 'Error: {}
' + hass.components.persistent_notification.create( + 'Error: {}
' 'You will need to restart hass after fixing.' ''.format(ex), title=NOTIFICATION_TITLE, diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index dd46f469a55..fbfe2b6959a 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TURN_ON) +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent @@ -56,6 +57,7 @@ SCENE_SERVICE_SCHEMA = vol.Schema({ }) +@bind_hass def activate(hass, entity_id=None): """Activate a scene.""" data = {} diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 996cef10d77..62edb11b778 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -16,6 +16,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS) from homeassistant.core import split_entity_id +from homeassistant.loader import bind_hass from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent import homeassistant.helpers.config_validation as cv @@ -55,16 +56,19 @@ SCRIPT_TURN_ONOFF_SCHEMA = vol.Schema({ RELOAD_SERVICE_SCHEMA = vol.Schema({}) +@bind_hass def is_on(hass, entity_id): """Return if the script is on based on the statemachine.""" return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def reload(hass): """Reload script component.""" hass.services.call(DOMAIN, SERVICE_RELOAD) +@bind_hass def turn_on(hass, entity_id, variables=None): """Turn script on.""" _, object_id = split_entity_id(entity_id) @@ -72,11 +76,13 @@ def turn_on(hass, entity_id, variables=None): hass.services.call(DOMAIN, object_id, variables) +@bind_hass def turn_off(hass, entity_id): """Turn script on.""" hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) +@bind_hass def toggle(hass, entity_id): """Toggle the script.""" hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index b0cde805796..32b82b15631 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -82,7 +82,7 @@ NETWORKS_RESPONSE_SCHEMA = vol.Schema({ STATION_SCHEMA = vol.Schema({ vol.Required(ATTR_FREE_BIKES): cv.positive_int, - vol.Required(ATTR_EMPTY_SLOTS): cv.positive_int, + vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None), vol.Required(ATTR_LATITUDE): cv.latitude, vol.Required(ATTR_LONGITUDE): cv.longitude, vol.Required(ATTR_ID): cv.string, diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index 1f24a0ee667..6056322cc24 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -20,7 +20,7 @@ from homeassistant.util.temperature import celsius_to_fahrenheit # Update this requirement to upstream as soon as it supports Python 3. REQUIREMENTS = ['https://github.com/adafruit/Adafruit_Python_DHT/archive/' 'da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip' - '#Adafruit_DHT==1.3.0'] + '#Adafruit_DHT==1.3.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 80e452e7b37..c0256e3a88b 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -15,9 +15,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.loader import get_component -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['fitbit==0.2.3'] @@ -32,6 +32,7 @@ ATTR_CLIENT_SECRET = 'client_secret' ATTR_LAST_SAVED_AT = 'last_saved_at' CONF_MONITORED_RESOURCES = 'monitored_resources' +CONF_ATTRIBUTION = 'Data provided by Fitbit.com' DEPENDENCIES = ['http'] @@ -40,9 +41,7 @@ FITBIT_AUTH_START = '/auth/fitbit' FITBIT_CONFIG_FILE = 'fitbit.conf' FITBIT_DEFAULT_RESOURCES = ['activities/steps'] -ICON = 'mdi:walk' - -MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=30) +SCAN_INTERVAL = datetime.timedelta(minutes=30) DEFAULT_CONFIG = { 'client_id': 'CLIENT_ID_HERE', @@ -74,6 +73,7 @@ FITBIT_RESOURCES_LIST = { 'activities/tracker/steps': 'steps', 'body/bmi': 'BMI', 'body/fat': '%', + 'devices/battery': 'level', 'sleep/awakeningsCount': 'times awaken', 'sleep/efficiency': '%', 'sleep/minutesAfterWakeup': 'minutes', @@ -95,6 +95,7 @@ FITBIT_MEASUREMENTS = { 'body': 'in', 'liquids': 'fl. oz.', 'blood glucose': 'mg/dL', + 'battery': '', }, 'en_GB': { 'duration': 'milliseconds', @@ -104,7 +105,8 @@ FITBIT_MEASUREMENTS = { 'weight': 'stone', 'body': 'centimeters', 'liquids': 'milliliters', - 'blood glucose': 'mmol/L' + 'blood glucose': 'mmol/L', + 'battery': '', }, 'metric': { 'duration': 'milliseconds', @@ -114,7 +116,8 @@ FITBIT_MEASUREMENTS = { 'weight': 'kilograms', 'body': 'centimeters', 'liquids': 'milliliters', - 'blood glucose': 'mmol/L' + 'blood glucose': 'mmol/L', + 'battery': '', } } @@ -253,11 +256,20 @@ def setup_platform(hass, config, add_devices, discovery_info=None): authd_client.system = 'en_US' dev = [] + registered_devs = authd_client.get_devices() for resource in config.get(CONF_MONITORED_RESOURCES): - dev.append(FitbitSensor( - authd_client, config_path, resource, - hass.config.units.is_metric)) - add_devices(dev) + + # monitor battery for all linked FitBit devices + if resource == 'devices/battery': + for dev_extra in registered_devs: + dev.append(FitbitSensor( + authd_client, config_path, resource, + hass.config.units.is_metric, dev_extra)) + else: + dev.append(FitbitSensor( + authd_client, config_path, resource, + hass.config.units.is_metric)) + add_devices(dev, True) else: oauth = fitbit.api.FitbitOauth2Client( @@ -348,11 +360,13 @@ class FitbitAuthCallbackView(HomeAssistantView): class FitbitSensor(Entity): """Implementation of a Fitbit sensor.""" - def __init__(self, client, config_path, resource_type, is_metric): + def __init__(self, client, config_path, resource_type, + is_metric, extra=None): """Initialize the Fitbit sensor.""" self.client = client self.config_path = config_path self.resource_type = resource_type + self.extra = extra pretty_resource = self.resource_type.replace('activities/', '') pretty_resource = pretty_resource.replace('/', ' ') pretty_resource = pretty_resource.title() @@ -360,6 +374,13 @@ class FitbitSensor(Entity): pretty_resource = 'BMI' elif pretty_resource == 'Heart': pretty_resource = 'Resting Heart Rate' + elif pretty_resource == 'Devices Battery': + if self.extra: + pretty_resource = \ + '{0} Battery'.format(self.extra.get('deviceVersion')) + else: + pretty_resource = 'Battery' + self._name = pretty_resource unit_type = FITBIT_RESOURCES_LIST[self.resource_type] if unit_type == "": @@ -374,7 +395,6 @@ class FitbitSensor(Entity): unit_type = measurement_system[split_resource[-1]] self._unit_of_measurement = unit_type self._state = 0 - self.update() @property def name(self): @@ -394,14 +414,32 @@ class FitbitSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return ICON + if self.resource_type == 'devices/battery': + return 'mdi:battery-50' + return 'mdi:walk' + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + + attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + + if self.extra: + attrs['model'] = self.extra.get('deviceVersion') + attrs['type'] = self.extra.get('type') + + return attrs - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from the Fitbit API and update the states.""" - container = self.resource_type.replace("/", "-") - response = self.client.time_series(self.resource_type, period='7d') - self._state = response[container][-1].get('value') + if self.resource_type == 'devices/battery' and self.extra: + self._state = self.extra.get('battery') + else: + container = self.resource_type.replace("/", "-") + response = self.client.time_series(self.resource_type, period='7d') + self._state = response[container][-1].get('value') + if self.resource_type == 'activities/heart': self._state = response[container][-1]. \ get('value').get('restingHeartRate') diff --git a/homeassistant/components/sensor/google_wifi.py b/homeassistant/components/sensor/google_wifi.py new file mode 100644 index 00000000000..d878f4f8d20 --- /dev/null +++ b/homeassistant/components/sensor/google_wifi.py @@ -0,0 +1,201 @@ +""" +Support for retreiving status info from Google Wifi/OnHub routers. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.google_wifi/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol +import requests + +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.util import Throttle +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_MONITORED_CONDITIONS, + STATE_UNKNOWN) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) + +_LOGGER = logging.getLogger(__name__) + +ENDPOINT = '/api/v1/status' + +ATTR_CURRENT_VERSION = 'current_version' +ATTR_NEW_VERSION = 'new_version' +ATTR_UPTIME = 'uptime' +ATTR_LAST_RESTART = 'last_restart' +ATTR_LOCAL_IP = 'local_ip' +ATTR_STATUS = 'status' + +DEFAULT_NAME = 'google_wifi' +DEFAULT_HOST = 'testwifi.here' + +MONITORED_CONDITIONS = { + ATTR_CURRENT_VERSION: [ + 'Current Version', + None, + 'mdi:checkbox-marked-circle-outline' + ], + ATTR_NEW_VERSION: [ + 'New Version', + None, + 'mdi:update' + ], + ATTR_UPTIME: [ + 'Uptime', + 'days', + 'mdi:timelapse' + ], + ATTR_LAST_RESTART: [ + 'Last Network Restart', + None, + 'mdi:restart' + ], + ATTR_LOCAL_IP: [ + 'Local IP Address', + None, + 'mdi:access-point-network' + ], + ATTR_STATUS: [ + 'Status', + None, + 'mdi:google' + ] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): 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 Google Wifi sensor.""" + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + + api = GoogleWifiAPI(host) + + sensors = [GoogleWifiSensor(hass, api, name, condition) + for condition in config[CONF_MONITORED_CONDITIONS]] + + add_devices(sensors, True) + + +class GoogleWifiSensor(Entity): + """Representation of a Google Wifi sensor.""" + + def __init__(self, hass, api, name, variable): + """Initialize a Pi-Hole sensor.""" + self._hass = hass + self._api = api + self._name = name + self._state = STATE_UNKNOWN + + variable_info = MONITORED_CONDITIONS[variable] + self._var_name = variable + 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 + + @property + def availiable(self): + """Return availiability of goole wifi api.""" + return self._api.availiable + + @property + def state(self): + """Return the state of the device.""" + return self._state + + def update(self): + """Get the latest data from the Google Wifi API.""" + self._api.update() + if self.availiable: + self._state = self._api.data[self._var_name] + else: + self._state = STATE_UNKNOWN + + +class GoogleWifiAPI(object): + """Get the latest data and update the states.""" + + def __init__(self, host): + """Initialize the data object.""" + uri = 'http://' + resource = "{}{}{}".format(uri, host, ENDPOINT) + + self._request = requests.Request('GET', resource).prepare() + self.raw_data = None + self.data = { + ATTR_CURRENT_VERSION: STATE_UNKNOWN, + ATTR_NEW_VERSION: STATE_UNKNOWN, + ATTR_UPTIME: STATE_UNKNOWN, + ATTR_LAST_RESTART: STATE_UNKNOWN, + ATTR_LOCAL_IP: STATE_UNKNOWN, + ATTR_STATUS: STATE_UNKNOWN + } + self.availiable = True + self.update() + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the router.""" + try: + _LOGGER.error("Before request") + with requests.Session() as sess: + response = sess.send( + self._request, timeout=10) + self.raw_data = response.json() + _LOGGER.error(self.raw_data) + self.data_format() + self.availiable = True + except ValueError: + _LOGGER.error("Unable to fetch data from Google Wifi") + self.availiable = False + self.raw_data = None + + def data_format(self): + """Format raw data into easily accessible dict.""" + for key, value in self.raw_data.items(): + if key == 'software': + self.data[ATTR_CURRENT_VERSION] = value['softwareVersion'] + if value['updateNewVersion'] == '0.0.0.0': + self.data[ATTR_NEW_VERSION] = 'Latest' + else: + self.data[ATTR_NEW_VERSION] = value['updateNewVersion'] + elif key == 'system': + self.data[ATTR_UPTIME] = value['uptime'] / (3600 * 24) + last_restart = dt.now() - timedelta(seconds=value['uptime']) + self.data[ATTR_LAST_RESTART] = \ + last_restart.strftime("%Y-%m-%d %H:%M:%S") + elif key == 'wan': + if value['online']: + self.data[ATTR_STATUS] = 'Online' + else: + self.data[ATTR_STATUS] = 'Offline' + if not value['ipAddress']: + self.data[ATTR_LOCAL_IP] = STATE_UNKNOWN + else: + self.data[ATTR_LOCAL_IP] = value['localIpAddress'] diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index 45a7b812039..884f101c033 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -39,21 +39,21 @@ SENSOR_TYPES = { 'balance': ['Balance', PRICE, 'mdi:square-inc-cash'], 'period_total_bill': - ['Current period bill', PRICE, 'mdi:square-inc-cash'], + ['Period total bill', PRICE, 'mdi:square-inc-cash'], 'period_length': - ['Current period length', DAYS, 'mdi:calendar-today'], + ['Period length', DAYS, 'mdi:calendar-today'], 'period_total_days': - ['Total number of days in this period', DAYS, 'mdi:calendar-today'], + ['Period total days', DAYS, 'mdi:calendar-today'], 'period_mean_daily_bill': - ['Period daily average bill', PRICE, 'mdi:square-inc-cash'], + ['Period mean daily bill', PRICE, 'mdi:square-inc-cash'], 'period_mean_daily_consumption': - ['Period daily average consumption', KILOWATT_HOUR, 'mdi:flash'], + ['Period mean daily consumption', KILOWATT_HOUR, 'mdi:flash'], 'period_total_consumption': - ['Total Consumption', KILOWATT_HOUR, 'mdi:flash'], + ['Period total consumption', KILOWATT_HOUR, 'mdi:flash'], 'period_lower_price_consumption': - ['Period Lower price consumption', KILOWATT_HOUR, 'mdi:flash'], + ['Period lower price consumption', KILOWATT_HOUR, 'mdi:flash'], 'period_higher_price_consumption': - ['Period Higher price consumption', KILOWATT_HOUR, 'mdi:flash'], + ['Period higher price consumption', KILOWATT_HOUR, 'mdi:flash'], 'yesterday_total_consumption': ['Yesterday total consumption', KILOWATT_HOUR, 'mdi:flash'], 'yesterday_lower_price_consumption': @@ -125,7 +125,6 @@ class HydroQuebecSensor(Entity): """Initialize the sensor.""" self.client_name = name self.type = sensor_type - self.entity_id = "sensor.{}_{}".format(name, sensor_type) self._name = SENSOR_TYPES[sensor_type][0] self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] self._icon = SENSOR_TYPES[sensor_type][2] diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index d4752666821..80a88ca925a 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -160,8 +160,12 @@ def convert_float(raw_value): Defined in KNX 3.7.2 - 3.10 """ from knxip.conversion import knx2_to_float + from knxip.core import KNXException - return knx2_to_float(raw_value) + 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): @@ -170,14 +174,11 @@ def convert_percent(raw_value): 1byte percentage scaled KNX Telegram. Defined in KNX 3.7.2 - 3.10. """ - summed_value = 0 + value = 0 try: - # convert raw value in bytes - for val in raw_value: - summed_value *= 256 - summed_value += val - except TypeError: + value = raw_value[0] + except (IndexError, ValueError): # pknx returns a non-iterable type for unsuccessful reads - pass + _LOGGER.error("Can't convert %s to percent value", raw_value) - return round(summed_value * 100 / 255) + return round(value * 100 / 255) diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index c16fae9f5d5..11ca07f7fb8 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -45,20 +45,27 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lyft sensor.""" from lyft_rides.auth import ClientCredentialGrant + from lyft_rides.errors import APIError auth_flow = ClientCredentialGrant(client_id=config.get(CONF_CLIENT_ID), client_secret=config.get( CONF_CLIENT_SECRET), scopes="public", is_sandbox_mode=False) - session = auth_flow.get_session() + try: + session = auth_flow.get_session() + + timeandpriceest = LyftEstimate( + session, config[CONF_START_LATITUDE], config[CONF_START_LONGITUDE], + config.get(CONF_END_LATITUDE), config.get(CONF_END_LONGITUDE)) + timeandpriceest.fetch_data() + except APIError as exc: + _LOGGER.error("Error setting up Lyft platform: %s", exc) + return False wanted_product_ids = config.get(CONF_PRODUCT_IDS) dev = [] - timeandpriceest = LyftEstimate( - session, config[CONF_START_LATITUDE], config[CONF_START_LONGITUDE], - config.get(CONF_END_LATITUDE), config.get(CONF_END_LONGITUDE)) for product_id, product in timeandpriceest.products.items(): if (wanted_product_ids is not None) and \ (product_id not in wanted_product_ids): @@ -188,14 +195,18 @@ class LyftEstimate(object): self.end_latitude = end_latitude self.end_longitude = end_longitude self.products = None - self.__real_update() @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest product info and estimates from the Lyft API.""" - self.__real_update() + from lyft_rides.errors import APIError + try: + self.fetch_data() + except APIError as exc: + _LOGGER.error("Error fetching Lyft data: %s", exc) - def __real_update(self): + def fetch_data(self): + """Get the latest product info and estimates from the Lyft API.""" from lyft_rides.client import LyftRidesClient client = LyftRidesClient(self._session) diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 150a97288cc..035799429f5 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -25,6 +25,8 @@ SENSOR_TYPES = { 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS], 'Current State': ['printer', 'state', 'text', None], 'Job Percentage': ['job', 'progress', 'completion', '%'], + 'Time Remaining': ['job', 'progress', 'printTimeLeft', 'seconds'], + 'Time Elapsed': ['job', 'progress', 'printTime', 'seconds'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index bacb25ce2c4..13421aca61d 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -36,11 +36,21 @@ SCAN_INTERVAL = timedelta(minutes=5) MONITORED_CONDITIONS = { 'dns_queries_today': ['DNS Queries Today', - None, 'mdi:network-question'], + None, 'mdi:comment-question-outline'], 'ads_blocked_today': ['Ads Blocked Today', None, 'mdi:close-octagon-outline'], 'ads_percentage_today': ['Ads Percentage Blocked Today', '%', 'mdi:close-octagon-outline'], + 'domains_being_blocked': ['Domains Blocked', + None, 'mdi:block-helper'], + 'queries_cached': ['DNS Queries Cached', + None, 'mdi:comment-question-outline'], + 'queries_forwarded': ['DNS Queries Forwarded', + None, 'mdi:comment-question-outline'], + 'unique_clients': ['DNS Unique Clients', + None, 'mdi:account-outline'], + 'unique_domains': ['DNS Unique Domains', + None, 'mdi:domain'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -104,7 +114,10 @@ class PiHoleSensor(Entity): @property def state(self): """Return the state of the device.""" - return self._api.data[self._var_id] + try: + return round(self._api.data[self._var_id], 2) + except TypeError: + return self._api.data[self._var_id] # pylint: disable=no-member @property diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index 42f68a1967a..20460f9063c 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -110,11 +110,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): api.update() if not api.data: - import homeassistant.loader as loader - loader.get_component('persistent_notification').create( - hass, 'Error: Failed to set up QNAP sensor.
' - 'Check the logs for additional information. ' - 'You will need to restart hass after fixing.', + hass.components.persistent_notification.create( + 'Error: Failed to set up QNAP sensor.
' + 'Check the logs for additional information. ' + 'You will need to restart hass after fixing.', title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) return False diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 75a6f68a0a2..361ce551426 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT) -REQUIREMENTS = ['pysnmp==4.3.8'] +REQUIREMENTS = ['pysnmp==4.3.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/uk_transport.py b/homeassistant/components/sensor/uk_transport.py new file mode 100644 index 00000000000..b9ce98ec257 --- /dev/null +++ b/homeassistant/components/sensor/uk_transport.py @@ -0,0 +1,275 @@ +"""Support for UK public transport data provided by transportapi.com. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uk_transport/ +""" +import logging +import re +from datetime import datetime, timedelta +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_ATCOCODE = 'atcocode' +ATTR_LOCALITY = 'locality' +ATTR_STOP_NAME = 'stop_name' +ATTR_REQUEST_TIME = 'request_time' +ATTR_NEXT_BUSES = 'next_buses' +ATTR_STATION_CODE = 'station_code' +ATTR_CALLING_AT = 'calling_at' +ATTR_NEXT_TRAINS = 'next_trains' + +CONF_API_APP_KEY = 'app_key' +CONF_API_APP_ID = 'app_id' +CONF_QUERIES = 'queries' +CONF_MODE = 'mode' +CONF_ORIGIN = 'origin' +CONF_DESTINATION = 'destination' + +_QUERY_SCHEME = vol.Schema({ + vol.Required(CONF_MODE): + vol.All(cv.ensure_list, [vol.In(list(['bus', 'train']))]), + vol.Required(CONF_ORIGIN): cv.string, + vol.Required(CONF_DESTINATION): cv.string, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_APP_ID): cv.string, + vol.Required(CONF_API_APP_KEY): cv.string, + vol.Required(CONF_QUERIES): [_QUERY_SCHEME], +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Get the uk_transport sensor.""" + sensors = [] + number_sensors = len(config.get(CONF_QUERIES)) + interval = timedelta(seconds=87*number_sensors) + + for query in config.get(CONF_QUERIES): + if 'bus' in query.get(CONF_MODE): + stop_atcocode = query.get(CONF_ORIGIN) + bus_direction = query.get(CONF_DESTINATION) + sensors.append( + UkTransportLiveBusTimeSensor( + config.get(CONF_API_APP_ID), + config.get(CONF_API_APP_KEY), + stop_atcocode, + bus_direction, + interval)) + + elif 'train' in query.get(CONF_MODE): + station_code = query.get(CONF_ORIGIN) + calling_at = query.get(CONF_DESTINATION) + sensors.append( + UkTransportLiveTrainTimeSensor( + config.get(CONF_API_APP_ID), + config.get(CONF_API_APP_KEY), + station_code, + calling_at, + interval)) + + add_devices(sensors, True) + + +class UkTransportSensor(Entity): + """ + Sensor that reads the UK transport web API. + + transportapi.com provides comprehensive transport data for UK train, tube + and bus travel across the UK via simple JSON API. Subclasses of this + base class can be used to access specific types of information. + """ + + TRANSPORT_API_URL_BASE = "https://transportapi.com/v3/uk/" + ICON = 'mdi:train' + + def __init__(self, name, api_app_id, api_app_key, url): + """Initialize the sensor.""" + self._data = {} + self._api_app_id = api_app_id + self._api_app_key = api_app_key + self._url = self.TRANSPORT_API_URL_BASE + url + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "min" + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self.ICON + + def _do_api_request(self, params): + """Perform an API request.""" + request_params = dict({ + 'app_id': self._api_app_id, + 'app_key': self._api_app_key, + }, **params) + + response = requests.get(self._url, params=request_params) + if response.status_code != 200: + _LOGGER.warning('Invalid response from API') + elif 'error' in response.json(): + if 'exceeded' in response.json()['error']: + self._state = 'Useage limites exceeded' + if 'invalid' in response.json()['error']: + self._state = 'Credentials invalid' + else: + self._data = response.json() + + +class UkTransportLiveBusTimeSensor(UkTransportSensor): + """Live bus time sensor from UK transportapi.com.""" + + ICON = 'mdi:bus' + + def __init__(self, api_app_id, api_app_key, + stop_atcocode, bus_direction, interval): + """Construct a live bus time sensor.""" + self._stop_atcocode = stop_atcocode + self._bus_direction = bus_direction + self._next_buses = [] + self._destination_re = re.compile( + '{}'.format(bus_direction), re.IGNORECASE + ) + + sensor_name = 'Next bus to {}'.format(bus_direction) + stop_url = 'bus/stop/{}/live.json'.format(stop_atcocode) + + UkTransportSensor.__init__( + self, sensor_name, api_app_id, api_app_key, stop_url + ) + self.update = Throttle(interval)(self._update) + + def _update(self): + """Get the latest live departure data for the specified stop.""" + params = {'group': 'route', 'nextbuses': 'no'} + + self._do_api_request(params) + + if self._data != {}: + self._next_buses = [] + + for (route, departures) in self._data['departures'].items(): + for departure in departures: + if self._destination_re.search(departure['direction']): + self._next_buses.append({ + 'route': route, + 'direction': departure['direction'], + 'scheduled': departure['aimed_departure_time'], + 'estimated': departure['best_departure_estimate'] + }) + + self._state = min(map( + _delta_mins, [bus['scheduled'] for bus in self._next_buses] + )) + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attrs = {} + if self._data is not None: + for key in [ + ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, + ATTR_REQUEST_TIME + ]: + attrs[key] = self._data.get(key) + attrs[ATTR_NEXT_BUSES] = self._next_buses + return attrs + + +class UkTransportLiveTrainTimeSensor(UkTransportSensor): + """Live train time sensor from UK transportapi.com.""" + + ICON = 'mdi:train' + + def __init__(self, api_app_id, api_app_key, + station_code, calling_at, interval): + """Construct a live bus time sensor.""" + self._station_code = station_code + self._calling_at = calling_at + self._next_trains = [] + + sensor_name = 'Next train to {}'.format(calling_at) + query_url = 'train/station/{}/live.json'.format(station_code) + + UkTransportSensor.__init__( + self, sensor_name, api_app_id, api_app_key, query_url + ) + self.update = Throttle(interval)(self._update) + + def _update(self): + """Get the latest live departure data for the specified stop.""" + params = {'darwin': 'false', + 'calling_at': self._calling_at, + 'train_status': 'passenger'} + + self._do_api_request(params) + self._next_trains = [] + + if self._data != {}: + if self._data['departures']['all'] == []: + self._state = 'No departures' + else: + for departure in self._data['departures']['all']: + self._next_trains.append({ + 'origin_name': departure['origin_name'], + 'destination_name': departure['destination_name'], + 'status': departure['status'], + 'scheduled': departure['aimed_departure_time'], + 'estimated': departure['expected_departure_time'], + 'platform': departure['platform'], + 'operator_name': departure['operator_name'] + }) + + self._state = min(map( + _delta_mins, + [train['scheduled'] for train in self._next_trains] + )) + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attrs = {} + if self._data is not None: + attrs[ATTR_STATION_CODE] = self._station_code + attrs[ATTR_CALLING_AT] = self._calling_at + if self._next_trains: + attrs[ATTR_NEXT_TRAINS] = self._next_trains + return attrs + + +def _delta_mins(hhmm_time_str): + """Calculate time delta in minutes to a time in hh:mm format.""" + now = datetime.now() + hhmm_time = datetime.strptime(hhmm_time_str, '%H:%M') + + hhmm_datetime = datetime( + now.year, now.month, now.day, + hour=hhmm_time.hour, minute=hhmm_time.minute + ) + if hhmm_datetime < now: + hhmm_datetime += timedelta(days=1) + + delta_mins = (hhmm_datetime - now).seconds // 60 + return delta_mins diff --git a/homeassistant/components/sensor/xiaomi.py b/homeassistant/components/sensor/xiaomi.py new file mode 100644 index 00000000000..994a6789bbf --- /dev/null +++ b/homeassistant/components/sensor/xiaomi.py @@ -0,0 +1,82 @@ +"""Support for Xiaomi sensors.""" +import logging + +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) +from homeassistant.const import TEMP_CELSIUS + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['sensor']: + if device['model'] == 'sensor_ht': + devices.append(XiaomiSensor(device, 'Temperature', + 'temperature', gateway)) + devices.append(XiaomiSensor(device, 'Humidity', + 'humidity', gateway)) + elif device['model'] == 'weather.v1': + devices.append(XiaomiSensor(device, 'Temperature', + 'temperature', gateway)) + devices.append(XiaomiSensor(device, 'Humidity', + 'humidity', gateway)) + devices.append(XiaomiSensor(device, 'Pressure', + 'pressure', gateway)) + elif device['model'] == 'sensor_motion.aq2': + devices.append(XiaomiSensor(device, 'Illumination', + 'lux', gateway)) + elif device['model'] == 'gateway': + devices.append(XiaomiSensor(device, 'Illumination', + 'illumination', gateway)) + add_devices(devices) + + +class XiaomiSensor(XiaomiDevice): + """Representation of a XiaomiSensor.""" + + def __init__(self, device, name, data_key, xiaomi_hub): + """Initialize the XiaomiSensor.""" + self._data_key = data_key + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + if self._data_key == 'temperature': + return TEMP_CELSIUS + elif self._data_key == 'humidity': + return '%' + elif self._data_key == 'illumination': + return 'lm' + elif self._data_key == 'lux': + return 'lx' + elif self._data_key == 'pressure': + return 'hPa' + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def parse_data(self, data): + """Parse data sent by gateway.""" + value = data.get(self._data_key) + if value is None: + return False + value = float(value) + if self._data_key == 'temperature' and value == 10000: + return False + elif self._data_key == 'humidity' and value == 0: + return False + elif self._data_key == 'illumination' and value == 0: + return False + elif self._data_key == 'pressure' and value == 0: + return False + if self._data_key in ['temperature', 'humidity', 'pressure']: + value /= 100 + elif self._data_key in ['illumination']: + value = max(value - 300, 0) + self._state = round(value, 2) + return True diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index eefcff5bd17..880a539927a 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -520,3 +520,16 @@ wake_on_lan: broadcast_address: description: Optional broadcast IP where to send the magic packet. example: '192.168.255.255' + +knx: + group_write: + description: Turn a light on + + fields: + address: + description: Group address(es) to write to + example: '1/1/0' + + data: + description: KNX data to send + example: 1 diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py new file mode 100644 index 00000000000..ad9fae67bc6 --- /dev/null +++ b/homeassistant/components/shopping_list.py @@ -0,0 +1,206 @@ +"""Component to manage a shoppling list.""" +import asyncio +import json +import logging +import os +import uuid + +import voluptuous as vol + +from homeassistant.const import HTTP_NOT_FOUND, HTTP_BAD_REQUEST +from homeassistant.core import callback +from homeassistant.components import http +from homeassistant.helpers import intent +import homeassistant.helpers.config_validation as cv + + +DOMAIN = 'shopping_list' +DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) +EVENT = 'shopping_list_updated' +INTENT_ADD_ITEM = 'HassShoppingListAddItem' +INTENT_LAST_ITEMS = 'HassShoppingListLastItems' +ITEM_UPDATE_SCHEMA = vol.Schema({ + 'complete': bool, + 'name': str, +}) +PERSISTENCE = '.shopping_list.json' + + +@asyncio.coroutine +def async_setup(hass, config): + """Initialize the shopping list.""" + data = hass.data[DOMAIN] = ShoppingData(hass) + yield from data.async_load() + + intent.async_register(hass, AddItemIntent()) + intent.async_register(hass, ListTopItemsIntent()) + + hass.http.register_view(ShoppingListView) + hass.http.register_view(UpdateShoppingListItemView) + hass.http.register_view(ClearCompletedItemsView) + + hass.components.conversation.async_register(INTENT_ADD_ITEM, [ + 'Add {item} to my shopping list', + ]) + hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ + 'What is on my shopping list' + ]) + + hass.components.frontend.register_built_in_panel( + 'shopping-list', 'Shopping List', 'mdi:cart') + + return True + + +class ShoppingData: + """Class to hold shopping list data.""" + + def __init__(self, hass): + """Initialize the shopping list.""" + self.hass = hass + self.items = [] + + @callback + def async_add(self, name): + """Add a shopping list item.""" + self.items.append({ + 'name': name, + 'id': uuid.uuid4().hex, + 'complete': False + }) + self.hass.async_add_job(self.save) + + @callback + def async_update(self, item_id, info): + """Update a shopping list item.""" + item = next((itm for itm in self.items if itm['id'] == item_id), None) + + if item is None: + raise KeyError + + info = ITEM_UPDATE_SCHEMA(info) + item.update(info) + self.hass.async_add_job(self.save) + return item + + @callback + def async_clear_completed(self): + """Clear completed items.""" + self.items = [itm for itm in self.items if not itm['complete']] + self.hass.async_add_job(self.save) + + @asyncio.coroutine + def async_load(self): + """Load items.""" + def load(): + """Load the items synchronously.""" + path = self.hass.config.path(PERSISTENCE) + if not os.path.isfile(path): + return [] + with open(path) as file: + return json.loads(file.read()) + + items = yield from self.hass.async_add_job(load) + self.items = items + + def save(self): + """Save the items.""" + with open(self.hass.config.path(PERSISTENCE), 'wt') as file: + file.write(json.dumps(self.items, sort_keys=True, indent=4)) + + +class AddItemIntent(intent.IntentHandler): + """Handle AddItem intents.""" + + intent_type = INTENT_ADD_ITEM + slot_schema = { + 'item': cv.string + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + slots = self.async_validate_slots(intent_obj.slots) + item = slots['item']['value'] + intent_obj.hass.data[DOMAIN].async_add(item) + + response = intent_obj.create_response() + response.async_set_speech( + "I've added {} to your shopping list".format(item)) + intent_obj.hass.bus.async_fire(EVENT) + return response + + +class ListTopItemsIntent(intent.IntentHandler): + """Handle AddItem intents.""" + + intent_type = INTENT_LAST_ITEMS + slot_schema = { + 'item': cv.string + } + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + items = intent_obj.hass.data[DOMAIN].items[-5:] + response = intent_obj.create_response() + + if not items: + response.async_set_speech( + "There are no items on your shopping list") + else: + response.async_set_speech( + "These are the top {} items on your shopping list: {}".format( + min(len(items), 5), + ', '.join(itm['name'] for itm in reversed(items)))) + return response + + +class ShoppingListView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/shopping_list' + name = "api:shopping_list" + + @callback + def get(self, request): + """Retrieve if API is running.""" + return self.json(request.app['hass'].data[DOMAIN].items) + + +class UpdateShoppingListItemView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/shopping_list/item/{item_id}' + name = "api:shopping_list:item:id" + + @callback + def post(self, request, item_id): + """Retrieve if API is running.""" + data = yield from request.json() + + try: + item = request.app['hass'].data[DOMAIN].async_update(item_id, data) + request.app['hass'].bus.async_fire(EVENT) + return self.json(item) + except KeyError: + return self.json_message('Item not found', HTTP_NOT_FOUND) + except vol.Invalid: + return self.json_message('Item not found', HTTP_BAD_REQUEST) + + +class ClearCompletedItemsView(http.HomeAssistantView): + """View to retrieve shopping list content.""" + + url = '/api/shopping_list/clear_completed' + name = "api:shopping_list:clear_completed" + + @callback + def post(self, request): + """Retrieve if API is running.""" + hass = request.app['hass'] + hass.data[DOMAIN].async_clear_completed() + hass.bus.async_fire(EVENT) + return self.json_message('Cleared completed items.') diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index b123de48158..6243de0b2d6 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -5,12 +5,10 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ import asyncio -import copy import json import logging import voluptuous as vol -from homeassistant.helpers import template, script, config_validation as cv -import homeassistant.loader as loader +from homeassistant.helpers import intent, config_validation as cv DOMAIN = 'snips' DEPENDENCIES = ['mqtt'] @@ -19,16 +17,10 @@ CONF_ACTION = 'action' INTENT_TOPIC = 'hermes/nlu/intentParsed' -LOGGER = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: { - CONF_INTENTS: { - cv.string: { - vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, - } - } - } + DOMAIN: {} }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -49,74 +41,34 @@ INTENT_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): """Activate Snips component.""" - mqtt = loader.get_component('mqtt') - intents = config[DOMAIN].get(CONF_INTENTS, {}) - handler = IntentHandler(hass, intents) - @asyncio.coroutine def message_received(topic, payload, qos): """Handle new messages on MQTT.""" - LOGGER.debug("New intent: %s", payload) - yield from handler.handle_intent(payload) + _LOGGER.debug("New intent: %s", payload) - yield from mqtt.async_subscribe(hass, INTENT_TOPIC, message_received) + try: + request = json.loads(payload) + except TypeError: + _LOGGER.error('Received invalid JSON: %s', payload) + return + + try: + request = INTENT_SCHEMA(request) + except vol.Invalid as err: + _LOGGER.error('Intent has invalid schema: %s. %s', err, request) + return + + intent_type = request['intent']['intentName'].split('__')[-1] + slots = {slot['slotName']: {'value': slot['value']['value']} + for slot in request.get('slots', [])} + + try: + yield from intent.async_handle( + hass, DOMAIN, intent_type, slots, request['input']) + except intent.IntentError: + _LOGGER.exception("Error while handling intent.") + + yield from hass.components.mqtt.async_subscribe( + INTENT_TOPIC, message_received) return True - - -class IntentHandler(object): - """Help handling intents.""" - - def __init__(self, hass, intents): - """Initialize the intent handler.""" - self.hass = hass - intents = copy.deepcopy(intents) - template.attach(hass, intents) - - for name, intent in intents.items(): - if CONF_ACTION in intent: - intent[CONF_ACTION] = script.Script( - hass, intent[CONF_ACTION], "Snips intent {}".format(name)) - - self.intents = intents - - @asyncio.coroutine - def handle_intent(self, payload): - """Handle an intent.""" - try: - response = json.loads(payload) - except TypeError: - LOGGER.error('Received invalid JSON: %s', payload) - return - - try: - response = INTENT_SCHEMA(response) - except vol.Invalid as err: - LOGGER.error('Intent has invalid schema: %s. %s', err, response) - return - - intent = response['intent']['intentName'].split('__')[-1] - config = self.intents.get(intent) - - if config is None: - LOGGER.warning("Received unknown intent %s. %s", intent, response) - return - - action = config.get(CONF_ACTION) - - if action is not None: - slots = self.parse_slots(response) - yield from action.async_run(slots) - - # pylint: disable=no-self-use - def parse_slots(self, response): - """Parse the intent slots.""" - parameters = {} - - for slot in response.get('slots', []): - key = slot['slotName'] - value = slot['value']['value'] - if value is not None: - parameters[key] = value - - return parameters diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py index 452faf8933b..3613f53c098 100644 --- a/homeassistant/components/statsd.py +++ b/homeassistant/components/statsd.py @@ -19,6 +19,7 @@ _LOGGER = logging.getLogger(__name__) CONF_ATTR = 'log_attributes' CONF_RATE = 'rate' +CONF_VALUE_MAP = 'value_mapping' DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8125 @@ -34,6 +35,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string, vol.Optional(CONF_RATE, default=DEFAULT_RATE): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(CONF_VALUE_MAP, default=None): dict, }), }, extra=vol.ALLOW_EXTRA) @@ -47,6 +49,7 @@ def setup(hass, config): port = conf.get(CONF_PORT) sample_rate = conf.get(CONF_RATE) prefix = conf.get(CONF_PREFIX) + value_mapping = conf.get(CONF_VALUE_MAP) show_attribute_flag = conf.get(CONF_ATTR) statsd_client = statsd.StatsClient(host=host, port=port, prefix=prefix) @@ -59,7 +62,10 @@ def setup(hass, config): return try: - _state = state_helper.state_as_number(state) + if value_mapping and state.state in value_mapping: + _state = float(value_mapping[state.state]) + else: + _state = state_helper.state_as_number(state) except ValueError: # Set the state to none and continue for any numeric attributes. _state = None diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 2af79a54313..a53c6c5c01f 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -13,6 +13,7 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.config import load_yaml_config_file +from homeassistant.loader import bind_hass from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -48,6 +49,7 @@ SWITCH_SERVICE_SCHEMA = vol.Schema({ _LOGGER = logging.getLogger(__name__) +@bind_hass def is_on(hass, entity_id=None): """Return if the switch is on based on the statemachine. @@ -57,24 +59,28 @@ def is_on(hass, entity_id=None): return hass.states.is_state(entity_id, STATE_ON) +@bind_hass def turn_on(hass, entity_id=None): """Turn all or specified switch on.""" hass.add_job(async_turn_on, hass, entity_id) @callback +@bind_hass def async_turn_on(hass, entity_id=None): """Turn all or specified switch on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) +@bind_hass def turn_off(hass, entity_id=None): """Turn all or specified switch off.""" hass.add_job(async_turn_off, hass, entity_id) @callback +@bind_hass def async_turn_off(hass, entity_id=None): """Turn all or specified switch off.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None @@ -82,6 +88,7 @@ def async_turn_off(hass, entity_id=None): hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) +@bind_hass def toggle(hass, entity_id=None): """Toggle all or specified switch.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 3a7f3ee0c80..6ea738d82bc 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -13,7 +13,6 @@ import socket import voluptuous as vol -import homeassistant.loader as loader from homeassistant.util.dt import utcnow from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( @@ -67,8 +66,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): config.get(CONF_MAC).encode().replace(b':', b'')) switch_type = config.get(CONF_TYPE) - persistent_notification = loader.get_component('persistent_notification') - @asyncio.coroutine def _learn_command(call): try: @@ -91,13 +88,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): log_msg = "Recieved packet is: {}".\ format(b64encode(packet).decode('utf8')) _LOGGER.info(log_msg) - persistent_notification.async_create( - hass, log_msg, title='Broadlink switch') + hass.components.persistent_notification.async_create( + log_msg, title='Broadlink switch') return yield from asyncio.sleep(1, loop=hass.loop) _LOGGER.error("Did not received any signal") - persistent_notification.async_create( - hass, "Did not received any signal", title='Broadlink switch') + hass.components.persistent_notification.async_create( + "Did not received any signal", title='Broadlink switch') @asyncio.coroutine def _send_packet(call): diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index fc185c9f6a3..5893b3419d5 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME) import homeassistant.helpers.config_validation as cv -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE, STATE_UNKNOWN REQUIREMENTS = ['fritzhome==1.0.2'] @@ -21,13 +21,15 @@ _LOGGER = logging.getLogger(__name__) # Standard Fritz Box IP DEFAULT_HOST = 'fritz.box' -ATTR_CURRENT_CONSUMPTION = 'Current Consumption' -ATTR_CURRENT_CONSUMPTION_UNIT = 'W' +ATTR_CURRENT_CONSUMPTION = 'current_consumption' +ATTR_CURRENT_CONSUMPTION_UNIT = 'current_consumption_unit' +ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = 'W' -ATTR_TOTAL_CONSUMPTION = 'Total Consumption' -ATTR_TOTAL_CONSUMPTION_UNIT = 'kWh' +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' -ATTR_TEMPERATURE = 'Temperature' +ATTR_TEMPERATURE_UNIT = 'temperature_unit' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, @@ -86,17 +88,21 @@ class FritzDectSwitch(SwitchDevice): if self.data.has_powermeter and \ self.data.current_consumption != STATE_UNKNOWN and \ self.data.total_consumption != STATE_UNKNOWN: - attrs[ATTR_CURRENT_CONSUMPTION] = "%.1f %s" % \ - (self.data.current_consumption, ATTR_CURRENT_CONSUMPTION_UNIT) - attrs[ATTR_TOTAL_CONSUMPTION] = "%.3f %s" % \ - (self.data.total_consumption, ATTR_TOTAL_CONSUMPTION_UNIT) + attrs[ATTR_CURRENT_CONSUMPTION] = "{:.1f}".format( + self.data.current_consumption) + attrs[ATTR_CURRENT_CONSUMPTION_UNIT] = "{}".format( + ATTR_CURRENT_CONSUMPTION_UNIT_VALUE) + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + self.data.total_consumption) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = "{}".format( + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE) if self.data.has_temperature and \ self.data.temperature != STATE_UNKNOWN: - attrs[ATTR_TEMPERATURE] = "%.1f %s" % \ - (self.units.temperature(self.data.temperature, TEMP_CELSIUS), - self.units.temperature_unit) - + attrs[ATTR_TEMPERATURE] = "{}".format( + self.units.temperature(self.data.temperature, TEMP_CELSIUS)) + attrs[ATTR_TEMPERATURE_UNIT] = "{}".format( + self.units.temperature_unit) return attrs @property diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index e9c282e4c45..1169e5a3d5d 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.mqtt import ( - CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN) + CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, + CONF_RETAIN) from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, @@ -50,6 +51,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), + config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_QOS), config.get(CONF_RETAIN), config.get(CONF_PAYLOAD_ON), @@ -62,13 +64,16 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttSwitch(SwitchDevice): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, name, state_topic, command_topic, qos, retain, - payload_on, payload_off, optimistic, value_template): + def __init__(self, name, state_topic, command_topic, availability_topic, + qos, retain, payload_on, payload_off, optimistic, + value_template): """Initialize the MQTT switch.""" self._state = False self._name = name self._state_topic = state_topic self._command_topic = command_topic + self._availability_topic = availability_topic + self._available = True if availability_topic is None else False self._qos = qos self._retain = retain self._payload_on = payload_on @@ -83,8 +88,8 @@ class MqttSwitch(SwitchDevice): This method is a coroutine. """ @callback - def message_received(topic, payload, qos): - """Handle new MQTT messages.""" + def state_message_received(topic, payload, qos): + """Handle new MQTT state messages.""" if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) @@ -95,12 +100,28 @@ class MqttSwitch(SwitchDevice): self.hass.async_add_job(self.async_update_ha_state()) + @callback + def availability_message_received(topic, payload, qos): + """Handle new MQTT availability messages.""" + if payload == self._payload_on: + self._available = True + elif payload == self._payload_off: + self._available = False + + self.hass.async_add_job(self.async_update_ha_state()) + if self._state_topic is None: # Force into optimistic mode. self._optimistic = True else: yield from mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self.hass, self._state_topic, state_message_received, + self._qos) + + if self._availability_topic is not None: + yield from mqtt.async_subscribe( + self.hass, self._availability_topic, + availability_message_received, self._qos) @property def should_poll(self): @@ -112,6 +133,11 @@ class MqttSwitch(SwitchDevice): """Return the name of the switch.""" return self._name + @property + def available(self) -> bool: + """Return if switch is available.""" + return self._available + @property def is_on(self): """Return true if device is on.""" diff --git a/homeassistant/components/switch/velbus.py b/homeassistant/components/switch/velbus.py new file mode 100644 index 00000000000..15090091a52 --- /dev/null +++ b/homeassistant/components/switch/velbus.py @@ -0,0 +1,111 @@ +""" +Support for Velbus switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.velbus/ +""" + +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME, CONF_DEVICES +from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA +from homeassistant.components.velbus import DOMAIN +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +SWITCH_SCHEMA = { + vol.Required('module'): cv.positive_int, + vol.Required('channel'): cv.positive_int, + vol.Required(CONF_NAME): cv.string +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): + vol.All(cv.ensure_list, [SWITCH_SCHEMA]) +}) + +DEPENDENCIES = ['velbus'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Switch.""" + velbus = hass.data[DOMAIN] + devices = [] + + for switch in config[CONF_DEVICES]: + devices.append(VelbusSwitch(switch, velbus)) + add_devices(devices) + return True + + +class VelbusSwitch(SwitchDevice): + """Representation of a switch.""" + + def __init__(self, switch, velbus): + """Initialize a Velbus switch.""" + self._velbus = velbus + self._name = switch[CONF_NAME] + self._module = switch['module'] + self._channel = switch['channel'] + self._state = False + + @asyncio.coroutine + def async_added_to_hass(self): + """Add listener for Velbus messages on bus.""" + def _init_velbus(): + """Initialize Velbus on startup.""" + self._velbus.subscribe(self._on_message) + self.get_status() + + yield from self.hass.async_add_job(_init_velbus) + + def _on_message(self, message): + import velbus + if isinstance(message, velbus.RelayStatusMessage) and \ + message.address == self._module and \ + message.channel == self._channel: + self._state = message.is_on() + self.schedule_update_ha_state() + + @property + def name(self): + """Return the display name of this switch.""" + return self._name + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the switch to turn on.""" + import velbus + message = velbus.SwitchRelayOnMessage() + message.set_defaults(self._module) + message.relay_channels = [self._channel] + self._velbus.send(message) + + def turn_off(self, **kwargs): + """Instruct the switch to turn off.""" + import velbus + message = velbus.SwitchRelayOffMessage() + message.set_defaults(self._module) + message.relay_channels = [self._channel] + self._velbus.send(message) + + def get_status(self): + """Retrieve current status.""" + import velbus + message = velbus.ModuleStatusRequestMessage() + message.set_defaults(self._module) + message.channels = [self._channel] + self._velbus.send(message) diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py index 0076355665c..ec9311ac9e9 100644 --- a/homeassistant/components/switch/wink.py +++ b/homeassistant/components/switch/wink.py @@ -32,6 +32,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _id = sprinkler.object_id() + sprinkler.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkToggleDevice(sprinkler, hass)]) + for switch in pywink.get_binary_switch_groups(): + _id = switch.object_id() + switch.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkToggleDevice(switch, hass)]) class WinkToggleDevice(WinkDevice, ToggleEntity): diff --git a/homeassistant/components/switch/xiaomi.py b/homeassistant/components/switch/xiaomi.py new file mode 100644 index 00000000000..3e4ea4f6d72 --- /dev/null +++ b/homeassistant/components/switch/xiaomi.py @@ -0,0 +1,124 @@ +"""Support for Xiaomi binary sensors.""" +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice) + +_LOGGER = logging.getLogger(__name__) + +ATTR_LOAD_POWER = 'Load power' # Load power in watts (W) +ATTR_POWER_CONSUMED = 'Power consumed' +ATTR_IN_USE = 'In use' +LOAD_POWER = 'load_power' +POWER_CONSUMED = 'power_consumed' +IN_USE = 'inuse' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + for device in gateway.devices['switch']: + model = device['model'] + if model == 'plug': + devices.append(XiaomiGenericSwitch(device, "Plug", 'status', + True, gateway)) + elif model == 'ctrl_neutral1': + devices.append(XiaomiGenericSwitch(device, 'Wall Switch', + 'channel_0', + False, gateway)) + elif model == 'ctrl_ln1': + devices.append(XiaomiGenericSwitch(device, 'Wall Switch LN', + 'channel_0', + False, gateway)) + elif model == 'ctrl_neutral2': + devices.append(XiaomiGenericSwitch(device, 'Wall Switch Left', + 'channel_0', + False, gateway)) + devices.append(XiaomiGenericSwitch(device, 'Wall Switch Right', + 'channel_1', + False, gateway)) + elif model == 'ctrl_ln2': + devices.append(XiaomiGenericSwitch(device, + 'Wall Switch LN Left', + 'channel_0', + False, gateway)) + devices.append(XiaomiGenericSwitch(device, + 'Wall Switch LN Right', + 'channel_1', + False, gateway)) + elif model == '86plug': + devices.append(XiaomiGenericSwitch(device, 'Wall Plug', + 'status', True, gateway)) + add_devices(devices) + + +class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice): + """Representation of a XiaomiPlug.""" + + def __init__(self, device, name, data_key, supports_power_consumption, + xiaomi_hub): + """Initialize the XiaomiPlug.""" + self._data_key = data_key + self._in_use = None + self._load_power = None + self._power_consumed = None + self._supports_power_consumption = supports_power_consumption + XiaomiDevice.__init__(self, device, name, xiaomi_hub) + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + if self._data_key == 'status': + return 'mdi:power-plug' + return 'mdi:power-socket' + + @property + def is_on(self): + """Return true if it is on.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._supports_power_consumption: + attrs = {ATTR_IN_USE: self._in_use, + ATTR_LOAD_POWER: self._load_power, + ATTR_POWER_CONSUMED: self._power_consumed} + else: + attrs = {} + attrs.update(super().device_state_attributes) + return attrs + + def turn_on(self, **kwargs): + """Turn the switch on.""" + if self._write_to_hub(self._sid, **{self._data_key: 'on'}): + self._state = True + self.schedule_update_ha_state() + + def turn_off(self): + """Turn the switch off.""" + if self._write_to_hub(self._sid, **{self._data_key: 'off'}): + self._state = False + self.schedule_update_ha_state() + + def parse_data(self, data): + """Parse data sent by gateway.""" + if IN_USE in data: + self._in_use = int(data[IN_USE]) + if not self._in_use: + self._load_power = 0 + if POWER_CONSUMED in data: + self._power_consumed = round(float(data[POWER_CONSUMED]), 2) + if LOAD_POWER in data: + self._load_power = round(float(data[LOAD_POWER]), 2) + + value = data.get(self._data_key) + if value is None: + return False + + state = value == 'on' + if self._state == state: + return False + self._state = state + return True diff --git a/homeassistant/components/switch/xiaomi_vacuum.py b/homeassistant/components/switch/xiaomi_vacuum.py index 20906dd8df1..393cabb72b9 100644 --- a/homeassistant/components/switch/xiaomi_vacuum.py +++ b/homeassistant/components/switch/xiaomi_vacuum.py @@ -22,7 +22,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, }) -REQUIREMENTS = ['python-mirobo==0.1.1'] +REQUIREMENTS = ['python-mirobo==0.1.2'] # pylint: disable=unused-argument @@ -32,7 +32,7 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) - add_devices_callback([MiroboSwitch(name, host, token)]) + add_devices_callback([MiroboSwitch(name, host, token)], True) class MiroboSwitch(SwitchDevice): @@ -107,7 +107,7 @@ class MiroboSwitch(SwitchDevice): def update(self): """Fetch state from the device.""" - from mirobo import VacuumException + from mirobo import DeviceException try: state = self.vacuum.status() _LOGGER.debug("got state from the vacuum: %s", state) @@ -120,5 +120,5 @@ class MiroboSwitch(SwitchDevice): self._state = state.state_code self._is_on = state.is_on - except VacuumException as ex: + except DeviceException as ex: _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 355a6d0a648..9e45def63db 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -9,8 +9,6 @@ from urllib.parse import urlsplit import voluptuous as vol -import homeassistant.loader as loader - from homeassistant.const import (EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery @@ -78,8 +76,6 @@ def setup(hass, config): if external_port == 0: external_port = internal_port - persistent_notification = loader.get_component('persistent_notification') - try: upnp.addportmapping( external_port, 'TCP', host, internal_port, 'Home Assistant', '') @@ -92,8 +88,8 @@ def setup(hass, config): except Exception as ex: _LOGGER.error("UPnP failed to configure port mapping: %s", str(ex)) - persistent_notification.create( - hass, 'ERROR: tcp port {} is already mapped in your router.' + hass.components.persistent_notification.create( + 'ERROR: tcp port {} is already mapped in your router.' '
Please disable port_mapping in the upnp ' 'configuration section.
' 'You will need to restart hass after fixing.' diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py new file mode 100644 index 00000000000..ff2db955d31 --- /dev/null +++ b/homeassistant/components/velbus.py @@ -0,0 +1,43 @@ +""" +Support for Velbus platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/velbus/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_PORT + +REQUIREMENTS = ['python-velbus==2.0.11'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'velbus' + + +VELBUS_MESSAGE = 'velbus.message' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PORT): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Velbus platform.""" + import velbus + port = config[DOMAIN].get(CONF_PORT) + connection = velbus.VelbusUSBConnection(port) + controller = velbus.Controller(connection) + hass.data[DOMAIN] = controller + + def stop_velbus(event): + """Disconnect from serial port.""" + _LOGGER.debug("Shutting down ") + connection.stop() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus) + return True diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index b43cea3fcea..2183e20188f 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.34'] +REQUIREMENTS = ['pyvera==0.2.35'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index 0e216273d65..687d919ed95 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -11,17 +11,21 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.weather import ( - WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_TEMP) + WeatherEntity, PLATFORM_SCHEMA, + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME) from homeassistant.const import (TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN) REQUIREMENTS = ["yahooweather==0.8"] _LOGGER = logging.getLogger(__name__) +DATA_CONDITION = 'yahoo_condition' + ATTR_FORECAST_CONDITION = 'condition' ATTRIBUTION = "Weather details provided by Yahoo! Inc." -CONF_FORECAST = 'forecast' +ATTR_FORECAST_TEMP_LOW = 'templow' + CONF_WOEID = 'woeid' DEFAULT_NAME = 'Yweather' @@ -33,23 +37,22 @@ CONDITION_CLASSES = { 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], 'lightning': [37], - 'lightning-rainy': [38, 39], + 'lightning-rainy': [38, 39, 47], 'partlycloudy': [44], 'pouring': [40, 45], 'rainy': [9, 11, 12], 'snowy': [8, 13, 14, 15, 16, 41, 42, 43], - 'snowy-rainy': [5, 6, 7, 10, 46, 47], - 'sunny': [32], + 'snowy-rainy': [5, 6, 7, 10, 46], + 'sunny': [32, 33, 34], 'windy': [24], 'windy-variant': [], 'exceptional': [0, 1, 2, 3, 4, 25, 36], } + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_WOEID, default=None): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_FORECAST, default=0): - vol.All(vol.Coerce(int), vol.Range(min=0, max=5)), }) @@ -59,7 +62,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): unit = hass.config.units.temperature_unit woeid = config.get(CONF_WOEID) - forecast = config.get(CONF_FORECAST) name = config.get(CONF_NAME) yunit = UNIT_C if unit == TEMP_CELSIUS else UNIT_F @@ -77,22 +79,23 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.critical("Can't retrieve weather data from Yahoo!") return False - if forecast >= len(yahoo_api.yahoo.Forecast): - _LOGGER.error("Yahoo! only support %d days forecast", - len(yahoo_api.yahoo.Forecast)) - return False + # create condition helper + if DATA_CONDITION not in hass.data: + hass.data[DATA_CONDITION] = [str(x) for x in range(0, 50)] + for cond, condlst in CONDITION_CLASSES.items(): + for condi in condlst: + hass.data[DATA_CONDITION][condi] = cond - add_devices([YahooWeatherWeather(yahoo_api, name, forecast)], True) + add_devices([YahooWeatherWeather(yahoo_api, name)], True) class YahooWeatherWeather(WeatherEntity): """Representation of Yahoo! weather data.""" - def __init__(self, weather_data, name, forecast): + def __init__(self, weather_data, name): """Initialize the sensor.""" self._name = name self._data = weather_data - self._forecast = forecast @property def name(self): @@ -103,9 +106,9 @@ class YahooWeatherWeather(WeatherEntity): def condition(self): """Return the current condition.""" try: - return [k for k, v in CONDITION_CLASSES.items() if - int(self._data.yahoo.Now['code']) in v][0] - except IndexError: + return self.hass.data[DATA_CONDITION][int( + self._data.yahoo.Now['code'])] + except (ValueError, IndexError): return STATE_UNKNOWN @property @@ -138,6 +141,11 @@ class YahooWeatherWeather(WeatherEntity): """Return the wind speed.""" return self._data.yahoo.Wind['speed'] + @property + def wind_bearing(self): + """Return the wind direction.""" + return self._data.yahoo.Wind['direction'] + @property def attribution(self): """Return the attribution.""" @@ -147,19 +155,17 @@ class YahooWeatherWeather(WeatherEntity): def forecast(self): """Return the forecast array.""" try: - forecast_condition = \ - [k for k, v in CONDITION_CLASSES.items() if - int(self._data.yahoo.Forecast[self._forecast]['code']) - in v][0] - except IndexError: + return [ + { + ATTR_FORECAST_TIME: v['date'], + ATTR_FORECAST_TEMP:int(v['high']), + ATTR_FORECAST_TEMP_LOW: int(v['low']), + ATTR_FORECAST_CONDITION: + self.hass.data[DATA_CONDITION][int(v['code'])] + } for v in self._data.yahoo.Forecast] + except (ValueError, IndexError): return STATE_UNKNOWN - return [{ - ATTR_FORECAST_CONDITION: forecast_condition, - ATTR_FORECAST_TEMP: - self._data.yahoo.Forecast[self._forecast]['high'], - }] - def update(self): """Get the latest data from Yahoo! and updates the states.""" self._data.update() diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py index 1c0410a4aa0..e6d60aa5568 100644 --- a/homeassistant/components/wink.py +++ b/homeassistant/components/wink.py @@ -7,56 +7,71 @@ https://home-assistant.io/components/wink/ import logging import time import json +import os from datetime import timedelta import voluptuous as vol +import requests +from homeassistant.loader import get_component +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView from homeassistant.helpers import discovery from homeassistant.helpers.event import track_time_interval from homeassistant.const import ( - CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + ATTR_BATTERY_LEVEL, CONF_EMAIL, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, __version__) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-wink==1.2.4', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.4.2', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) -CHANNELS = [] - DOMAIN = 'wink' SUBSCRIPTION_HANDLER = None + CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' CONF_USER_AGENT = 'user_agent' -CONF_OATH = 'oath' +CONF_OAUTH = 'oauth' +CONF_LOCAL_CONTROL = 'local_control' CONF_APPSPOT = 'appspot' -CONF_DEFINED_BOTH_MSG = 'Remove access token to use oath2.' -CONF_MISSING_OATH_MSG = 'Missing oath2 credentials.' +CONF_MISSING_OAUTH_MSG = 'Missing oauth2 credentials.' CONF_TOKEN_URL = "https://winkbearertoken.appspot.com/token" +ATTR_ACCESS_TOKEN = 'access_token' +ATTR_REFRESH_TOKEN = 'refresh_token' +ATTR_CLIENT_ID = 'client_id' +ATTR_CLIENT_SECRET = 'client_secret' + +WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' +WINK_AUTH_START = '/auth/wink' +WINK_CONFIG_FILE = '.wink.conf' +USER_AGENT = "Manufacturer/Home-Assistant%s python/3 Wink/3" % (__version__) + +DEFAULT_CONFIG = { + 'client_id': 'CLIENT_ID_HERE', + 'client_secret': 'CLIENT_SECRET_HERE' +} + SERVICE_ADD_NEW_DEVICES = 'add_new_devices' SERVICE_REFRESH_STATES = 'refresh_state_from_wink' +SERVICE_KEEP_ALIVE = 'keep_pubnub_updates_flowing' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Inclusive(CONF_EMAIL, CONF_APPSPOT, - msg=CONF_MISSING_OATH_MSG): cv.string, + msg=CONF_MISSING_OAUTH_MSG): cv.string, vol.Inclusive(CONF_PASSWORD, CONF_APPSPOT, - msg=CONF_MISSING_OATH_MSG): cv.string, - vol.Inclusive(CONF_CLIENT_ID, CONF_OATH, - msg=CONF_MISSING_OATH_MSG): cv.string, - vol.Inclusive(CONF_CLIENT_SECRET, CONF_OATH, - msg=CONF_MISSING_OATH_MSG): cv.string, - vol.Exclusive(CONF_EMAIL, CONF_OATH, - msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Exclusive(CONF_ACCESS_TOKEN, CONF_OATH, - msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Exclusive(CONF_ACCESS_TOKEN, CONF_APPSPOT, - msg=CONF_DEFINED_BOTH_MSG): cv.string, - vol.Optional(CONF_USER_AGENT, default=None): cv.string + msg=CONF_MISSING_OAUTH_MSG): cv.string, + vol.Inclusive(CONF_CLIENT_ID, CONF_OAUTH, + msg=CONF_MISSING_OAUTH_MSG): cv.string, + vol.Inclusive(CONF_CLIENT_SECRET, CONF_OAUTH, + msg=CONF_MISSING_OAUTH_MSG): cv.string, + vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean }) }, extra=vol.ALLOW_EXTRA) @@ -66,30 +81,118 @@ WINK_COMPONENTS = [ ] +def _write_config_file(file_path, config): + try: + with open(file_path, 'w') as conf_file: + conf_file.write(json.dumps(config, sort_keys=True, indent=4)) + except IOError as error: + _LOGGER.error("Saving config file failed: %s", error) + raise IOError("Saving Wink config file failed") + return config + + +def _read_config_file(file_path): + try: + with open(file_path, 'r') as conf_file: + return json.loads(conf_file.read()) + except IOError as error: + _LOGGER.error("Reading config file failed: %s", error) + raise IOError("Reading Wink config file failed") + + +def _request_app_setup(hass, config): + """Assist user with configuring the Wink dev application.""" + hass.data[DOMAIN]['configurator'] = True + configurator = get_component('configurator') + + # pylint: disable=unused-argument + def wink_configuration_callback(callback_data): + """Handle configuration updates.""" + _config_path = hass.config.path(WINK_CONFIG_FILE) + if not os.path.isfile(_config_path): + setup(hass, config) + return + + client_id = callback_data.get('client_id') + client_secret = callback_data.get('client_secret') + if None not in (client_id, client_secret): + _write_config_file(_config_path, + {ATTR_CLIENT_ID: client_id, + ATTR_CLIENT_SECRET: client_secret}) + setup(hass, config) + return + else: + error_msg = ("Your input was invalid. Please try again.") + _configurator = hass.data[DOMAIN]['configuring'][DOMAIN] + configurator.notify_errors(_configurator, error_msg) + + start_url = "{}{}".format(hass.config.api.base_url, + WINK_AUTH_CALLBACK_PATH) + + description = """Please create a Wink developer app at + https://developer.wink.com. + Add a Redirect URI of {}. + They will provide you a Client ID and secret + after reviewing your request. + (This can take several days). + """.format(start_url) + + hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( + hass, DOMAIN, wink_configuration_callback, + description=description, submit_caption="submit", + description_image="/static/images/config_wink.png", + fields=[{'id': 'client_id', 'name': 'Client ID', 'type': 'string'}, + {'id': 'client_secret', + 'name': 'Client secret', + 'type': 'string'}] + ) + + +def _request_oauth_completion(hass, config): + """Request user complete Wink OAuth2 flow.""" + hass.data[DOMAIN]['configurator'] = True + configurator = get_component('configurator') + if DOMAIN in hass.data[DOMAIN]['configuring']: + configurator.notify_errors( + hass.data[DOMAIN]['configuring'][DOMAIN], + "Failed to register, please try again.") + return + + # pylint: disable=unused-argument + def wink_configuration_callback(callback_data): + """Call setup again.""" + setup(hass, config) + + start_url = '{}{}'.format(hass.config.api.base_url, WINK_AUTH_START) + + description = "Please authorize Wink by visiting {}".format(start_url) + + hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config( + hass, DOMAIN, wink_configuration_callback, + description=description + ) + + def setup(hass, config): """Set up the Wink component.""" import pywink - import requests from pubnubsubhandler import PubNubSubscriptionHandler - hass.data[DOMAIN] = {} - hass.data[DOMAIN]['entities'] = [] - hass.data[DOMAIN]['unique_ids'] = [] - hass.data[DOMAIN]['entities'] = {} - - user_agent = config[DOMAIN].get(CONF_USER_AGENT) - - if user_agent: - pywink.set_user_agent(user_agent) - - access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN) - client_id = config[DOMAIN].get('client_id') + if hass.data.get(DOMAIN) is None: + hass.data[DOMAIN] = { + 'unique_ids': [], + 'entities': {}, + 'oauth': {}, + 'configuring': {}, + 'pubnub': None, + 'configurator': False + } def _get_wink_token_from_web(): - email = hass.data[DOMAIN]["oath"]["email"] - password = hass.data[DOMAIN]["oath"]["password"] + _email = hass.data[DOMAIN]["oauth"]["email"] + _password = hass.data[DOMAIN]["oauth"]["password"] - payload = {'username': email, 'password': password} + payload = {'username': _email, 'password': _password} token_response = requests.post(CONF_TOKEN_URL, data=payload) try: token = token_response.text.split(':')[1].split()[0].rstrip('Wink Auth +

{}

""" + + if data.get('code') is not None: + response = self.request_token(data.get('code'), + self.config_file["client_secret"]) + + config_contents = { + ATTR_ACCESS_TOKEN: response['access_token'], + ATTR_REFRESH_TOKEN: response['refresh_token'], + ATTR_CLIENT_ID: self.config_file["client_id"], + ATTR_CLIENT_SECRET: self.config_file["client_secret"] + } + _write_config_file(hass.config.path(WINK_CONFIG_FILE), + config_contents) + + hass.async_add_job(setup, hass, self.config) + + return web.Response(text=html_response.format(response_message), + content_type='text/html') + + error_msg = "No code returned from Wink API" + _LOGGER.error(error_msg) + return web.Response(text=html_response.format(error_msg), + content_type='text/html') + + class WinkDevice(Entity): """Representation a base Wink device.""" diff --git a/homeassistant/components/xiaomi.py b/homeassistant/components/xiaomi.py new file mode 100644 index 00000000000..377446a66c8 --- /dev/null +++ b/homeassistant/components/xiaomi.py @@ -0,0 +1,197 @@ +"""Support for Xiaomi Gateways.""" +import logging +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.const import (ATTR_BATTERY_LEVEL, EVENT_HOMEASSISTANT_STOP, + CONF_MAC) + + +REQUIREMENTS = ['https://github.com/Danielhiversen/PyXiaomiGateway/archive/' + 'aa9325fe6fdd62a8ef8c9ca1dce31d3292f484bb.zip#' + 'PyXiaomiGateway==0.2.0'] + +ATTR_GW_MAC = 'gw_mac' +ATTR_RINGTONE_ID = 'ringtone_id' +ATTR_RINGTONE_VOL = 'ringtone_vol' +CONF_DISCOVERY_RETRY = 'discovery_retry' +CONF_GATEWAYS = 'gateways' +CONF_INTERFACE = 'interface' +DOMAIN = 'xiaomi' +PY_XIAOMI_GATEWAY = "xiaomi_gw" + + +def _validate_conf(config): + """Validate a list of devices definitions.""" + res_config = [] + for gw_conf in config: + res_gw_conf = {'sid': gw_conf.get(CONF_MAC)} + if res_gw_conf['sid'] is not None: + res_gw_conf['sid'] = res_gw_conf['sid'].replace(":", "").lower() + if len(res_gw_conf['sid']) != 12: + raise vol.Invalid('Invalid mac address', gw_conf.get(CONF_MAC)) + key = gw_conf.get('key') + if key is None: + _LOGGER.warning( + 'Gateway Key is not provided.' + ' Controlling gateway device will not be possible.') + elif len(key) != 16: + raise vol.Invalid('Invalid key %s.' + ' Key must be 16 characters', key) + res_gw_conf['key'] = key + res_config.append(res_gw_conf) + return res_config + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_GATEWAYS, default=[{CONF_MAC: None, "key": None}]): + vol.All(cv.ensure_list, _validate_conf), + vol.Optional(CONF_INTERFACE, default='any'): cv.string, + vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int + }) +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """Set up the Xiaomi component.""" + gateways = config[DOMAIN][CONF_GATEWAYS] + interface = config[DOMAIN][CONF_INTERFACE] + discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY] + + from PyXiaomiGateway import PyXiaomiGateway + hass.data[PY_XIAOMI_GATEWAY] = PyXiaomiGateway(hass.add_job, gateways, + interface) + + _LOGGER.debug("Expecting %s gateways", len(gateways)) + for _ in range(discovery_retry): + _LOGGER.info('Discovering Xiaomi Gateways (Try %s)', _ + 1) + hass.data[PY_XIAOMI_GATEWAY].discover_gateways() + if len(hass.data[PY_XIAOMI_GATEWAY].gateways) >= len(gateways): + break + + if not hass.data[PY_XIAOMI_GATEWAY].gateways: + _LOGGER.error("No gateway discovered") + return False + hass.data[PY_XIAOMI_GATEWAY].listen() + _LOGGER.debug("Listening for broadcast") + + for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: + discovery.load_platform(hass, component, DOMAIN, {}, config) + + def stop_xiaomi(event): + """Stop Xiaomi Socket.""" + _LOGGER.info("Shutting down Xiaomi Hub.") + hass.data[PY_XIAOMI_GATEWAY].stop_listen() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi) + + def play_ringtone_service(call): + """Service to play ringtone through Gateway.""" + ring_id = call.data.get(ATTR_RINGTONE_ID) + gw_sid = call.data.get(ATTR_GW_MAC) + if ring_id is None or gw_sid is None: + _LOGGER.error("Mandatory parameters is not specified.") + return + + ring_id = int(ring_id) + if ring_id in [9, 14-19]: + _LOGGER.error('Specified mid: %s is not defined in gateway.', + ring_id) + return + + ring_vol = call.data.get(ATTR_RINGTONE_VOL) + if ring_vol is None: + ringtone = {'mid': ring_id} + else: + ringtone = {'mid': ring_id, 'vol': int(ring_vol)} + + gw_sid = gw_sid.replace(":", "").lower() + + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + if gateway.sid == gw_sid: + gateway.write_to_hub(gateway.sid, **ringtone) + break + else: + _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + + def stop_ringtone_service(call): + """Service to stop playing ringtone on Gateway.""" + gw_sid = call.data.get(ATTR_GW_MAC) + if gw_sid is None: + _LOGGER.error("Mandatory parameter (%s) is not specified.", + ATTR_GW_MAC) + return + + gw_sid = gw_sid.replace(":", "").lower() + for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items(): + if gateway.sid == gw_sid: + ringtone = {'mid': 10000} + gateway.write_to_hub(gateway.sid, **ringtone) + break + else: + _LOGGER.error('Unknown gateway sid: %s was specified.', gw_sid) + + hass.services.async_register(DOMAIN, 'play_ringtone', + play_ringtone_service, + description=None, schema=None) + hass.services.async_register(DOMAIN, 'stop_ringtone', + stop_ringtone_service, + description=None, schema=None) + return True + + +class XiaomiDevice(Entity): + """Representation a base Xiaomi device.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the xiaomi device.""" + self._state = None + self._sid = device['sid'] + self._name = '{}_{}'.format(name, self._sid) + self._write_to_hub = xiaomi_hub.write_to_hub + self._get_from_hub = xiaomi_hub.get_from_hub + xiaomi_hub.callbacks[self._sid].append(self.push_data) + self._device_state_attributes = {} + self.parse_data(device['data']) + self.parse_voltage(device['data']) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Poll update device status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device_state_attributes + + def push_data(self, data): + """Push from Hub.""" + _LOGGER.debug("PUSH >> %s: %s", self, data) + if self.parse_data(data) or self.parse_voltage(data): + self.schedule_update_ha_state() + + def parse_voltage(self, data): + """Parse battery level data sent by gateway.""" + if 'voltage' not in data: + return False + max_volt = 3300 + min_volt = 2800 + voltage = data['voltage'] + voltage = min(voltage, max_volt) + voltage = max(voltage, min_volt) + percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100 + self._device_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1) + return True + + def parse_data(self, data): + """Parse data sent by gateway.""" + raise NotImplementedError() diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py index e1a8b8e721b..712abfb1b6e 100644 --- a/homeassistant/components/zone.py +++ b/homeassistant/components/zone.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.const import ( ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON) +from homeassistant.loader import bind_hass from homeassistant.helpers import config_per_platform from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.util.async import run_callback_threadsafe @@ -50,6 +51,7 @@ PLATFORM_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +@bind_hass def active_zone(hass, latitude, longitude, radius=0): """Find the active zone for given latitude, longitude.""" return run_callback_threadsafe( @@ -57,6 +59,7 @@ def active_zone(hass, latitude, longitude, radius=0): ).result() +@bind_hass def async_active_zone(hass, latitude, longitude, radius=0): """Find the active zone for given latitude, longitude. diff --git a/homeassistant/config.py b/homeassistant/config.py index d91854c5162..a4b7bce5dc0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1,6 +1,8 @@ """Module to help with parsing and generating configuration files.""" import asyncio from collections import OrderedDict +# pylint: disable=no-name-in-module +from distutils.version import LooseVersion # pylint: disable=import-error import logging import os import re @@ -38,7 +40,7 @@ CONFIG_DIR_NAME = '.homeassistant' DATA_CUSTOMIZE = 'hass_customize' FILE_MIGRATION = [ - ["ios.conf", ".ios.conf"], + ['ios.conf', '.ios.conf'], ] DEFAULT_CORE_CONFIG = ( @@ -69,7 +71,7 @@ config: http: # Uncomment this to add a password (recommended!) # api_password: PASSWORD - # Uncomment this if you are using SSL or running in Docker etc + # Uncomment this if you are using SSL/TLS, running in Docker container, etc. # base_url: example.duckdns.org:8123 # Checks for available updates @@ -87,7 +89,7 @@ discovery: # Allows you to issue voice commands from the frontend in enabled browsers conversation: -# Enables support for tracking state changes over time. +# Enables support for tracking state changes over time history: # View all events in a logbook @@ -96,13 +98,13 @@ logbook: # Track the sun sun: -# Weather Prediction +# Weather prediction sensor: - platform: yr + - platform: yr # Text to speech tts: - platform: google + - platform: google group: !include groups.yaml automation: !include automations.yaml @@ -139,17 +141,17 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({ def get_default_config_dir() -> str: - """Put together the default configuration directory based on OS.""" + """Put together the default configuration directory based on the OS.""" data_dir = os.getenv('APPDATA') if os.name == "nt" \ else os.path.expanduser('~') return os.path.join(data_dir, CONFIG_DIR_NAME) def ensure_config_exists(config_dir: str, detect_location: bool=True) -> str: - """Ensure a config file exists in given configuration directory. + """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. - Return path to the config file. + Return path to the configuration file. """ config_path = find_config_file(config_dir) @@ -193,8 +195,8 @@ def create_default_config(config_dir, detect_location=True): info[attr] = getattr(location_info, prop) or default if location_info.latitude and location_info.longitude: - info[CONF_ELEVATION] = loc_util.elevation(location_info.latitude, - location_info.longitude) + info[CONF_ELEVATION] = loc_util.elevation( + location_info.latitude, location_info.longitude) # Writing files with YAML does not create the most human readable results # So we're hard coding a YAML template. @@ -223,16 +225,16 @@ def create_default_config(config_dir, detect_location=True): return config_path except IOError: - print('Unable to create default configuration file', config_path) + print("Unable to create default configuration file", config_path) return None @asyncio.coroutine def async_hass_config_yaml(hass): - """Load YAML from hass config File. + """Load YAML from a Home Assistant configuration file. - This function allow component inside asyncio loop to reload his config by - self. + This function allow a component inside the asyncio loop to reload its + configuration by itself. This method is a coroutine. """ @@ -267,7 +269,7 @@ def load_yaml_config_file(config_path): getattr(err, 'filename', err))) if not isinstance(conf_dict, dict): - msg = 'The configuration file {} does not contain a dictionary'.format( + msg = "The configuration file {} does not contain a dictionary".format( os.path.basename(config_path)) _LOGGER.error(msg) raise HomeAssistantError(msg) @@ -276,7 +278,7 @@ def load_yaml_config_file(config_path): def process_ha_config_upgrade(hass): - """Upgrade config if necessary. + """Upgrade configuration if necessary. This method needs to run in an executor. """ @@ -292,30 +294,32 @@ def process_ha_config_upgrade(hass): if conf_version == __version__: return - _LOGGER.info('Upgrading config directory from %s to %s', conf_version, - __version__) + _LOGGER.info("Upgrading configuration directory from %s to %s", + conf_version, __version__) - lib_path = hass.config.path('deps') - if os.path.isdir(lib_path): - shutil.rmtree(lib_path) + if LooseVersion(conf_version) < LooseVersion('0.50'): + # 0.50 introduced persistent deps dir. + lib_path = hass.config.path('deps') + if os.path.isdir(lib_path): + shutil.rmtree(lib_path) with open(version_path, 'wt') as outp: outp.write(__version__) - _LOGGER.info('Migrating old system config files to new locations') + _LOGGER.info("Migrating old system configuration files to new locations") for oldf, newf in FILE_MIGRATION: if os.path.isfile(hass.config.path(oldf)): - _LOGGER.info('Migrating %s to %s', oldf, newf) + _LOGGER.info("Migrating %s to %s", oldf, newf) os.rename(hass.config.path(oldf), hass.config.path(newf)) @callback def async_log_exception(ex, domain, config, hass): - """Generate log exception for config validation. + """Generate log exception for configuration validation. This method must be run in the event loop. """ - message = 'Invalid config for [{}]: '.format(domain) + message = "Invalid config for [{}]: ".format(domain) if hass is not None: async_notify_setup_error(hass, domain, True) @@ -340,7 +344,7 @@ def async_log_exception(ex, domain, config, hass): @asyncio.coroutine def async_process_ha_core_config(hass, config): - """Process the [homeassistant] section from the config. + """Process the [homeassistant] section from the configuration. This method is a coroutine. """ @@ -358,7 +362,7 @@ def async_process_ha_core_config(hass, config): hac.time_zone = time_zone date_util.set_default_time_zone(time_zone) else: - _LOGGER.error('Received invalid time zone %s', time_zone_str) + _LOGGER.error("Received invalid time zone %s", time_zone_str) for key, attr in ((CONF_LATITUDE, 'latitude'), (CONF_LONGITUDE, 'longitude'), @@ -370,7 +374,7 @@ def async_process_ha_core_config(hass, config): if CONF_TIME_ZONE in config: set_time_zone(config.get(CONF_TIME_ZONE)) - # init whitelist external dir + # Init whitelist external dir hac.whitelist_external_dirs = set((hass.config.path('www'),)) if CONF_WHITELIST_EXTERNAL_DIRS in config: hac.whitelist_external_dirs.update( @@ -390,7 +394,7 @@ def async_process_ha_core_config(hass, config): try: pkg_cust = CUSTOMIZE_CONFIG_SCHEMA(pkg_cust) except vol.Invalid: - _LOGGER.warning('Package %s contains invalid customize', name) + _LOGGER.warning("Package %s contains invalid customize", name) continue cust_exact.update(pkg_cust[CONF_CUSTOMIZE]) @@ -411,9 +415,9 @@ def async_process_ha_core_config(hass, config): hac.units = METRIC_SYSTEM else: hac.units = IMPERIAL_SYSTEM - _LOGGER.warning("Found deprecated temperature unit in core config, " - "expected unit system. Replace '%s: %s' with " - "'%s: %s'", CONF_TEMPERATURE_UNIT, unit, + _LOGGER.warning("Found deprecated temperature unit in core " + "configuration expected unit system. Replace '%s: %s' " + "with '%s: %s'", CONF_TEMPERATURE_UNIT, unit, CONF_UNIT_SYSTEM, hac.units.name) # Shortcut if no auto-detection necessary @@ -430,7 +434,7 @@ def async_process_ha_core_config(hass, config): loc_util.detect_location_info) if info is None: - _LOGGER.error('Could not detect location information') + _LOGGER.error("Could not detect location information") return if hac.latitude is None and hac.longitude is None: @@ -459,8 +463,8 @@ def async_process_ha_core_config(hass, config): if discovered: _LOGGER.warning( - 'Incomplete core config. Auto detected %s', - ', '.join('{}: {}'.format(key, val) for key, val in discovered)) + "Incomplete core configuration. Auto detected %s", + ", ".join('{}: {}'.format(key, val) for key, val in discovered)) def _log_pkg_error(package, component, config, message): @@ -491,7 +495,7 @@ def _identify_config_schema(module): def merge_packages_config(config, packages): - """Merge packages into the top-level config. Mutate config.""" + """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) for pack_name, pack_conf in packages.items(): @@ -545,7 +549,7 @@ def merge_packages_config(config, packages): if comp_name in config: _log_pkg_error( pack_name, comp_name, config, "may occur only once" - " and it already exist in your main config") + " and it already exist in your main configuration") continue config[comp_name] = comp_conf @@ -554,7 +558,7 @@ def merge_packages_config(config, packages): @callback def async_process_component_config(hass, config, domain): - """Check component config and return processed config. + """Check component configuration and return processed configuration. Raise a vol.Invalid exception on error. @@ -615,7 +619,7 @@ def async_process_component_config(hass, config, domain): @asyncio.coroutine def async_check_ha_config_file(hass): - """Check if HA config file valid. + """Check if Home Assistant configuration file is valid. This method is a coroutine. """ diff --git a/homeassistant/const.py b/homeassistant/const.py index 25990b102a8..751aa316084 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 = 49 -PATCH_VERSION = '1' +MINOR_VERSION = 50 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 4, 2) @@ -188,7 +188,9 @@ STATE_HOME = 'home' STATE_NOT_HOME = 'not_home' STATE_UNKNOWN = 'unknown' STATE_OPEN = 'open' +STATE_OPENING = 'opening' STATE_CLOSED = 'closed' +STATE_CLOSING = 'closing' STATE_PLAYING = 'playing' STATE_PAUSED = 'paused' STATE_IDLE = 'idle' diff --git a/homeassistant/core.py b/homeassistant/core.py index d1779fe420d..496bb018fbd 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -30,6 +30,7 @@ from homeassistant.const import ( EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, __version__) +from homeassistant.loader import Components from homeassistant.exceptions import ( HomeAssistantError, InvalidEntityFormatError) from homeassistant.util.async import ( @@ -128,6 +129,7 @@ class HomeAssistant(object): self.services = ServiceRegistry(self) self.states = StateMachine(self.bus, self.loop) self.config = Config() # type: Config + self.components = Components(self) # This is a dictionary that any component can store any data on. self.data = {} self.state = CoreState.not_running diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py new file mode 100644 index 00000000000..8843bf53df9 --- /dev/null +++ b/homeassistant/helpers/intent.py @@ -0,0 +1,165 @@ +"""Module to coordinate user intentions.""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + + +DATA_KEY = 'intent' +_LOGGER = logging.getLogger(__name__) + +SLOT_SCHEMA = vol.Schema({ +}, extra=vol.ALLOW_EXTRA) + +SPEECH_TYPE_PLAIN = 'plain' +SPEECH_TYPE_SSML = 'ssml' + + +@callback +def async_register(hass, handler): + """Register an intent with Home Assistant.""" + intents = hass.data.get(DATA_KEY) + if intents is None: + intents = hass.data[DATA_KEY] = {} + + if handler.intent_type in intents: + _LOGGER.warning('Intent %s is being overwritten by %s.', + handler.intent_type, handler) + + intents[handler.intent_type] = handler + + +@asyncio.coroutine +def async_handle(hass, platform, intent_type, slots=None, text_input=None): + """Handle an intent.""" + handler = hass.data.get(DATA_KEY, {}).get(intent_type) + + if handler is None: + raise UnknownIntent() + + intent = Intent(hass, platform, intent_type, slots or {}, text_input) + + try: + _LOGGER.info("Triggering intent handler %s", handler) + result = yield from handler.async_handle(intent) + return result + except vol.Invalid as err: + raise InvalidSlotInfo from err + except Exception as err: + raise IntentHandleError from err + + +class IntentError(HomeAssistantError): + """Base class for intent related errors.""" + + pass + + +class UnknownIntent(IntentError): + """When the intent is not registered.""" + + pass + + +class InvalidSlotInfo(IntentError): + """When the slot data is invalid.""" + + pass + + +class IntentHandleError(IntentError): + """Error while handling intent.""" + + pass + + +class IntentHandler: + """Intent handler registration.""" + + intent_type = None + slot_schema = None + _slot_schema = None + platforms = None + + @callback + def async_can_handle(self, intent_obj): + """Test if an intent can be handled.""" + return self.platforms is None or intent_obj.platform in self.platforms + + @callback + def async_validate_slots(self, slots): + """Validate slot information.""" + if self.slot_schema is None: + return slots + + if self._slot_schema is None: + self._slot_schema = vol.Schema({ + key: SLOT_SCHEMA.extend({'value': validator}) + for key, validator in self.slot_schema.items()}) + + return self._slot_schema(slots) + + @asyncio.coroutine + def async_handle(self, intent_obj): + """Handle the intent.""" + raise NotImplementedError() + + def __repr__(self): + """String representation of intent handler.""" + return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) + + +class Intent: + """Hold the intent.""" + + __slots__ = ['hass', 'platform', 'intent_type', 'slots', 'text_input'] + + def __init__(self, hass, platform, intent_type, slots, text_input): + """Initialize an intent.""" + self.hass = hass + self.platform = platform + self.intent_type = intent_type + self.slots = slots + self.text_input = text_input + + @callback + def create_response(self): + """Create a response.""" + return IntentResponse(self) + + +class IntentResponse: + """Response to an intent.""" + + def __init__(self, intent=None): + """Initialize an IntentResponse.""" + self.intent = intent + self.speech = {} + self.card = {} + + @callback + def async_set_speech(self, speech, speech_type='plain', extra_data=None): + """Set speech response.""" + self.speech[speech_type] = { + 'speech': speech, + 'extra_data': extra_data, + } + + @callback + def async_set_card(self, title, content, card_type='simple'): + """Set speech response.""" + self.card[card_type] = { + 'title': title, + 'content': content, + } + + @callback + def as_dict(self): + """Return a dictionary representation of an intent response.""" + return { + 'speech': self.speech, + 'card': self.card, + } diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 19113f243d2..93953fcd69e 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -24,17 +24,19 @@ from homeassistant.components.climate import ( from homeassistant.components.climate.ecobee import ( ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) +from homeassistant.components.cover import ( + ATTR_POSITION) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, - SERVICE_CLOSE_COVER, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_HOME, - STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, - STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_UNLOCKED, - SERVICE_SELECT_OPTION) + SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, + STATE_CLOSED, STATE_HOME, STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, + STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, + STATE_UNLOCKED, SERVICE_SELECT_OPTION) from homeassistant.core import State from homeassistant.util.async import run_coroutine_threadsafe @@ -63,7 +65,8 @@ SERVICE_ATTRIBUTES = { SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT], SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], SERVICE_SEND_IR_CODE: [ATTR_IR_CODE], - SERVICE_SELECT_OPTION: [ATTR_OPTION] + SERVICE_SELECT_OPTION: [ATTR_OPTION], + SERVICE_SET_COVER_POSITION: [ATTR_POSITION] } # Update this dict when new services are added to HA. diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 586988a3436..566cdd4fb15 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -10,6 +10,7 @@ call get_component('switch.your_platform'). In both cases the config directory is checked to see if it contains a user provided version. If not available it will check the built-in components and platforms. """ +import functools as ft import importlib import logging import os @@ -170,6 +171,49 @@ def get_component(comp_name) -> Optional[ModuleType]: return None +class Components: + """Helper to load components.""" + + def __init__(self, hass): + """Initialize the Components class.""" + self._hass = hass + + def __getattr__(self, comp_name): + """Fetch a component.""" + component = get_component(comp_name) + if component is None: + raise ImportError('Unable to load {}'.format(comp_name)) + wrapped = ComponentWrapper(self._hass, component) + setattr(self, comp_name, wrapped) + return wrapped + + +class ComponentWrapper: + """Class to wrap a component and auto fill in hass argument.""" + + def __init__(self, hass, component): + """Initialize the component wrapper.""" + self._hass = hass + self._component = component + + def __getattr__(self, attr): + """Fetch an attribute.""" + value = getattr(self._component, attr) + + if hasattr(value, '__bind_hass'): + value = ft.partial(value, self._hass) + + setattr(self, attr, value) + return value + + +def bind_hass(func): + """Decorator to indicate that first argument is hass.""" + # pylint: disable=protected-access + func.__bind_hass = True + return func + + def load_order_component(comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1de3671a296..9414bc98dc8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,7 +1,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 -pip>=7.1.0 +pip>=8.0.3 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index af9e00626dd..f6934aef8f6 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -1,14 +1,17 @@ """Home Assistant command line scripts.""" import argparse import importlib +import logging import os import sys -import logging + from typing import List -from homeassistant.config import get_default_config_dir -from homeassistant.util.package import install_package from homeassistant.bootstrap import mount_local_lib_path +from homeassistant.config import get_default_config_dir +from homeassistant.const import CONSTRAINT_FILE +from homeassistant.util.package import ( + install_package, running_under_virtualenv) def run(args: List) -> int: @@ -40,7 +43,14 @@ def run(args: List) -> int: logging.basicConfig(stream=sys.stdout, level=logging.INFO) for req in getattr(script, 'REQUIREMENTS', []): - if not install_package(req, target=deps_dir): + if running_under_virtualenv(): + returncode = install_package(req, constraints=os.path.join( + os.path.dirname(__file__), os.pardir, CONSTRAINT_FILE)) + else: + returncode = install_package( + req, target=deps_dir, constraints=os.path.join( + os.path.dirname(__file__), os.pardir, CONSTRAINT_FILE)) + if not returncode: print('Aborting scipt, could not install dependency', req) return 1 diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py new file mode 100644 index 00000000000..9ba945626e2 --- /dev/null +++ b/homeassistant/scripts/credstash.py @@ -0,0 +1,71 @@ +"""Script to get, put and delete secrets stored in credstash.""" +import argparse +import getpass + +from homeassistant.util.yaml import _SECRET_NAMESPACE + +REQUIREMENTS = ['credstash==1.13.2', 'botocore==1.4.93'] + + +def run(args): + """Handle credstash script.""" + parser = argparse.ArgumentParser( + description=("Modify Home-Assistant secrets in credstash." + "Use the secrets in configuration files with: " + "!secret ")) + parser.add_argument( + '--script', choices=['credstash']) + parser.add_argument( + 'action', choices=['get', 'put', 'del', 'list'], + help="Get, put or delete a secret, or list all available secrets") + parser.add_argument( + 'name', help="Name of the secret", nargs='?', default=None) + parser.add_argument( + 'value', help="The value to save when putting a secret", + nargs='?', default=None) + + import credstash + import botocore + + args = parser.parse_args(args) + table = _SECRET_NAMESPACE + + try: + credstash.listSecrets(table=table) + except botocore.errorfactory.ClientError: + credstash.createDdbTable(table=table) + + if args.action == 'list': + secrets = [i['name'] for i in credstash.listSecrets(table=table)] + deduped_secrets = sorted(set(secrets)) + + print('Saved secrets:') + for secret in deduped_secrets: + print(secret) + return 0 + + if args.name is None: + parser.print_help() + return 1 + + if args.action == 'put': + if args.value: + the_secret = args.value + else: + the_secret = getpass.getpass('Please enter the secret for {}: ' + .format(args.name)) + current_version = credstash.getHighestVersion(args.name, table=table) + credstash.putSecret(args.name, + the_secret, + version=int(current_version) + 1, + table=table) + print('Secret {} put successfully'.format(args.name)) + elif args.action == 'get': + the_secret = credstash.getSecret(args.name, table=table) + if the_secret is None: + print('Secret {} not found'.format(args.name)) + else: + print('Secret {}={}'.format(args.name, the_secret)) + elif args.action == 'del': + credstash.deleteSecrets(args.name, table=table) + print('Deleted secret {}'.format(args.name)) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 285a5755145..a7083d010e6 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -77,6 +77,10 @@ def _async_process_requirements(hass: core.HomeAssistant, name: str, def pip_install(mod): """Install packages.""" + if pkg_util.running_under_virtualenv(): + return pkg_util.install_package( + mod, constraints=os.path.join( + os.path.dirname(__file__), CONSTRAINT_FILE)) return pkg_util.install_package( mod, target=hass.config.path('deps'), constraints=os.path.join( diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index a5a863b0880..a82a50f4e02 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -1,11 +1,13 @@ """Helpers to install PyPi packages.""" +import asyncio import logging import os +from subprocess import PIPE, Popen import sys import threading -from subprocess import Popen, PIPE from urllib.parse import urlparse +from pip.locations import running_under_virtualenv from typing import Optional import pkg_resources @@ -24,20 +26,26 @@ def install_package(package: str, upgrade: bool=True, """ # Not using 'import pip; pip.main([])' because it breaks the logger with INSTALL_LOCK: - if check_package_exists(package, target): + if check_package_exists(package): return True - _LOGGER.info("Attempting install of %s", package) + _LOGGER.info('Attempting install of %s', package) + env = os.environ.copy() args = [sys.executable, '-m', 'pip', 'install', '--quiet', package] if upgrade: args.append('--upgrade') - if target: - args += ['--target', os.path.abspath(target)] - if constraints is not None: args += ['--constraint', constraints] - - process = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE) + if target: + assert not running_under_virtualenv() + # This only works if not running in venv + args += ['--user'] + env['PYTHONUSERBASE'] = os.path.abspath(target) + if sys.platform != 'win32': + # Workaround for incompatible prefix setting + # See http://stackoverflow.com/a/4495175 + args += ['--prefix='] + process = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) _, stderr = process.communicate() if process.returncode != 0: _LOGGER.error("Unable to install package %s: %s", @@ -47,7 +55,7 @@ def install_package(package: str, upgrade: bool=True, return True -def check_package_exists(package: str, lib_dir: str) -> bool: +def check_package_exists(package: str) -> bool: """Check if a package is installed globally or in lib_dir. Returns True when the requirement is met. @@ -59,12 +67,38 @@ def check_package_exists(package: str, lib_dir: str) -> bool: # This is a zip file req = pkg_resources.Requirement.parse(urlparse(package).fragment) - # Check packages from lib dir - if lib_dir is not None: - if any(dist in req for dist in - pkg_resources.find_distributions(lib_dir)): - return True + env = pkg_resources.Environment() + return any(dist in req for dist in env[req.project_name]) - # Check packages from global + virtual environment - # pylint: disable=not-an-iterable - return any(dist in req for dist in pkg_resources.working_set) + +def _get_user_site(deps_dir: str) -> tuple: + """Get arguments and environment for subprocess used in get_user_site.""" + env = os.environ.copy() + env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) + args = [sys.executable, '-m', 'site', '--user-site'] + return args, env + + +def get_user_site(deps_dir: str) -> str: + """Return user local library path.""" + args, env = _get_user_site(deps_dir) + process = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + stdout, _ = process.communicate() + lib_dir = stdout.decode().strip() + return lib_dir + + +@asyncio.coroutine +def async_get_user_site(deps_dir: str, loop: asyncio.AbstractEventLoop) -> str: + """Return user local library path. + + This function is a coroutine. + """ + args, env = _get_user_site(deps_dir) + process = yield from asyncio.create_subprocess_exec( + *args, loop=loop, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + env=env) + stdout, _ = yield from process.communicate() + lib_dir = stdout.decode().strip() + return lib_dir diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 7827f484fdf..4129a67bf57 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -12,6 +12,11 @@ try: except ImportError: keyring = None +try: + import credstash +except ImportError: + credstash = None + from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) @@ -200,8 +205,13 @@ def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node): def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node): """Load environment variables and embed it into the configuration YAML.""" - if node.value in os.environ: - return os.environ[node.value] + args = node.value.split() + + # Check for a default value + if len(args) > 1: + return os.getenv(args[0], ' '.join(args[1:])) + elif args[0] in os.environ: + return os.environ[args[0]] else: _LOGGER.error("Environment variable %s not defined.", node.value) raise HomeAssistantError(node.value) @@ -257,6 +267,15 @@ def _secret_yaml(loader: SafeLineLoader, _LOGGER.debug("Secret %s retrieved from keyring", node.value) return pwd + if credstash: + try: + pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE) + if pwd: + _LOGGER.debug("Secret %s retrieved from credstash", node.value) + return pwd + except credstash.ItemNotFound: + pass + _LOGGER.error("Secret %s not defined", node.value) raise HomeAssistantError(node.value) diff --git a/pylintrc b/pylintrc index e94cbffe9f9..1ed8d2af336 100644 --- a/pylintrc +++ b/pylintrc @@ -14,6 +14,8 @@ reports=no # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing +generated-members=botocore.errorfactory + disable= abstract-class-little-used, abstract-class-not-used, diff --git a/requirements_all.txt b/requirements_all.txt index a2188f8b6a2..84e3618d2a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2,7 +2,7 @@ requests==2.14.2 pyyaml>=3.11,<4 pytz>=2017.02 -pip>=7.1.0 +pip>=8.0.3 jinja2>=2.9.5 voluptuous==0.10.5 typing>=3,<4 @@ -21,7 +21,7 @@ astral==1.4 PyISY==1.0.7 # homeassistant.components.notify.html5 -PyJWT==1.5.0 +PyJWT==1.5.2 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -36,7 +36,7 @@ PyMata==2.14 SoCo==0.12 # homeassistant.components.notify.twitter -TwitterAPI==2.4.5 +TwitterAPI==2.4.6 # homeassistant.components.device_tracker.automatic aioautomatic==0.4.0 @@ -49,7 +49,7 @@ aiodns==1.1.1 aiohttp_cors==0.5.3 # homeassistant.components.light.lifx -aiolifx==0.5.2 +aiolifx==0.5.4 # homeassistant.components.light.lifx aiolifx_effects==0.1.1 @@ -58,7 +58,7 @@ aiolifx_effects==0.1.1 aiopvapi==1.4 # homeassistant.components.alarmdecoder -alarmdecoder==0.12.1.0 +alarmdecoder==0.12.3 # homeassistant.components.amcrest amcrest==1.2.1 @@ -106,12 +106,18 @@ blinkstick==1.1.8 # homeassistant.components.sensor.bitcoin blockchain==1.3.3 +# homeassistant.components.light.decora +# bluepy==1.1.1 + # homeassistant.components.notify.aws_lambda # homeassistant.components.notify.aws_sns # homeassistant.components.notify.aws_sqs # homeassistant.components.tts.amazon_polly boto3==1.4.3 +# homeassistant.scripts.credstash +botocore==1.4.93 + # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink broadlink==0.5 @@ -133,6 +139,9 @@ colorlog>2.1,<3 # homeassistant.components.binary_sensor.concord232 concord232==0.14 +# homeassistant.scripts.credstash +credstash==1.13.2 + # homeassistant.components.sensor.crimereports crimereports==1.0.0 @@ -226,7 +235,7 @@ fritzhome==1.0.2 fsapi==0.0.7 # homeassistant.components.conversation -fuzzywuzzy==0.15.0 +fuzzywuzzy==0.15.1 # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -273,8 +282,11 @@ holidays==0.8.1 # homeassistant.components.camera.onvif 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/aa9325fe6fdd62a8ef8c9ca1dce31d3292f484bb.zip#PyXiaomiGateway==0.2.0 + # homeassistant.components.sensor.dht -# https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.0 +# https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 @@ -338,10 +350,10 @@ jsonrpc-websocket==0.5 keyring>=9.3,<10.0 # homeassistant.components.knx -knxip==0.4 +knxip==0.5 # homeassistant.components.device_tracker.owntracks -libnacl==1.5.1 +libnacl==1.5.2 # homeassistant.components.dyson libpurecoollink==0.2.0 @@ -398,7 +410,7 @@ myusps==1.1.2 nad_receiver==0.0.6 # homeassistant.components.discovery -netdisco==1.0.1 +netdisco==1.1.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -497,6 +509,7 @@ py-cpuinfo==3.3.0 # homeassistant.components.hdmi_cec pyCEC==0.4.13 +# homeassistant.components.light.tplink # homeassistant.components.switch.tplink pyHS100==0.2.4.2 @@ -529,7 +542,7 @@ pybbox==0.0.5-alpha # pybluez==0.22 # homeassistant.components.media_player.cast -pychromecast==0.8.1 +pychromecast==0.8.2 # homeassistant.components.media_player.cmus pycmus==0.1.0 @@ -577,7 +590,7 @@ pyharmony==1.0.16 pyhik==0.1.3 # homeassistant.components.homematic -pyhomematic==0.1.29 +pyhomematic==0.1.30 # homeassistant.components.sensor.hydroquebec pyhydroquebec==1.2.0 @@ -671,7 +684,7 @@ pysma==0.1.3 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp -pysnmp==4.3.8 +pysnmp==4.3.9 # homeassistant.components.sensor.thinkingcleaner # homeassistant.components.switch.thinkingcleaner @@ -712,7 +725,7 @@ python-juicenet==0.0.5 # python-lirc==1.2.3 # homeassistant.components.switch.xiaomi_vacuum -python-mirobo==0.1.1 +python-mirobo==0.1.2 # homeassistant.components.media_player.mpd python-mpd2==0.5.5 @@ -748,11 +761,14 @@ python-telegram-bot==6.1.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 +# homeassistant.components.velbus +python-velbus==2.0.11 + # homeassistant.components.media_player.vlc python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.2.4 +python-wink==1.4.2 # homeassistant.components.zwave python_openzwave==0.4.0.31 @@ -770,7 +786,7 @@ pyunifi==2.13 # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.34 +pyvera==0.2.35 # homeassistant.components.media_player.vizio pyvizio==0.0.2 @@ -779,7 +795,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.0.5 +pywebpush==1.0.6 # homeassistant.components.wemo pywemo==0.4.19 @@ -837,7 +853,7 @@ sense-hat==2.2.0 sharp_aquos_rc==0.3.2 # homeassistant.components.alarm_control_panel.simplisafe -simplisafe-python==1.0.2 +simplisafe-python==1.0.3 # homeassistant.components.notify.slack slacker==0.9.50 @@ -865,7 +881,7 @@ speedtest-cli==1.0.6 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.11 +sqlalchemy==1.1.12 # homeassistant.components.statsd statsd==3.2.1 @@ -965,7 +981,7 @@ yeelight==0.3.0 yeelightsunflower==0.0.8 # homeassistant.components.media_extractor -youtube_dl==2017.7.9 +youtube_dl==2017.7.23 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test.txt b/requirements_test.txt index 95f9bfef329..cc54c21c18b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -3,7 +3,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.511 +mypy==0.520 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58fdcecf63c..01f94812031 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -4,7 +4,7 @@ # new version flake8==3.3 pylint==1.6.5 -mypy==0.511 +mypy==0.520 pydocstyle==1.1.1 coveralls>=1.1 pytest>=2.9.2 @@ -21,7 +21,7 @@ freezegun>=0.3.8 # homeassistant.components.notify.html5 -PyJWT==1.5.0 +PyJWT==1.5.2 # homeassistant.components.media_player.sonos SoCo==0.12 @@ -43,7 +43,7 @@ dsmr_parser==0.8 evohomeclient==0.2.5 # homeassistant.components.conversation -fuzzywuzzy==0.15.0 +fuzzywuzzy==0.15.1 # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -108,7 +108,7 @@ python-forecastio==1.3.5 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.0.5 +pywebpush==1.0.6 # homeassistant.components.python_script restrictedpython==4.0a3 @@ -130,7 +130,7 @@ somecomfort==0.4.1 # homeassistant.components.recorder # homeassistant.scripts.db_migrator -sqlalchemy==1.1.11 +sqlalchemy==1.1.12 # homeassistant.components.statsd statsd==3.2.1 diff --git a/setup.py b/setup.py index 4476bc2f9f0..d19a074889a 100755 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ REQUIRES = [ 'requests==2.14.2', 'pyyaml>=3.11,<4', 'pytz>=2017.02', - 'pip>=7.1.0', + 'pip>=8.0.3', 'jinja2>=2.9.5', 'voluptuous==0.10.5', 'typing>=3,<4', diff --git a/tests/common.py b/tests/common.py index b2001a3c837..5e328959a7a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,9 +14,7 @@ from aiohttp import web from homeassistant import core as ha, loader from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.helpers.restore_state import DATA_RESTORE_CACHE +from homeassistant.helpers import intent, dispatcher, entity, restore_state from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util import homeassistant.util.yaml as yaml @@ -193,12 +191,31 @@ def async_mock_service(hass, domain, service): mock_service = threadsafe_callback_factory(async_mock_service) +@ha.callback +def async_mock_intent(hass, intent_typ): + """Set up a fake intent handler.""" + intents = [] + + class MockIntentHandler(intent.IntentHandler): + intent_type = intent_typ + + @asyncio.coroutine + def async_handle(self, intent): + """Handle the intent.""" + intents.append(intent) + return intent.create_response() + + intent.async_register(hass, MockIntentHandler()) + + return intents + + @ha.callback def async_fire_mqtt_message(hass, topic, payload, qos=0): """Fire the MQTT message.""" if isinstance(payload, str): payload = payload.encode('utf-8') - async_dispatcher_send( + dispatcher.async_dispatcher_send( hass, mqtt.SIGNAL_MQTT_MESSAGE_RECEIVED, topic, payload, qos) @@ -352,7 +369,7 @@ class MockPlatform(object): self._setup_platform(hass, config, add_devices, discovery_info) -class MockToggleDevice(ToggleEntity): +class MockToggleDevice(entity.ToggleEntity): """Provide a mock toggle device.""" def __init__(self, name, state): @@ -506,10 +523,11 @@ def init_recorder_component(hass, add_config=None): def mock_restore_cache(hass, states): """Mock the DATA_RESTORE_CACHE.""" - hass.data[DATA_RESTORE_CACHE] = { + key = restore_state.DATA_RESTORE_CACHE + hass.data[key] = { state.entity_id: state for state in states} - _LOGGER.debug('Restore cache: %s', hass.data[DATA_RESTORE_CACHE]) - assert len(hass.data[DATA_RESTORE_CACHE]) == len(states), \ + _LOGGER.debug('Restore cache: %s', hass.data[key]) + assert len(hass.data[key]) == len(states), \ "Duplicate entity_id? {}".format(states) hass.state = ha.CoreState.starting mock_component(hass, recorder.DOMAIN) diff --git a/tests/components/alarm_control_panel/test_manual_mqtt.py b/tests/components/alarm_control_panel/test_manual_mqtt.py new file mode 100644 index 00000000000..c4dcd57ca39 --- /dev/null +++ b/tests/components/alarm_control_panel/test_manual_mqtt.py @@ -0,0 +1,559 @@ +"""The tests for the manual_mqtt Alarm Control Panel component.""" +from datetime import timedelta +import unittest +from unittest.mock import patch + +from homeassistant.setup import setup_component +from homeassistant.const import ( + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) +from homeassistant.components import alarm_control_panel +import homeassistant.util.dt as dt_util + +from tests.common import ( + fire_time_changed, get_test_home_assistant, + mock_mqtt_component, fire_mqtt_message, assert_setup_component) + +CODE = 'HELLO_CODE' + + +class TestAlarmControlPanelManualMqtt(unittest.TestCase): + """Test the manual_mqtt alarm module.""" + + def setUp(self): # pylint: disable=invalid-name + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.mock_publish = mock_mqtt_component(self.hass) + + def tearDown(self): # pylint: disable=invalid-name + """Stop down everything that was started.""" + self.hass.stop() + + def test_fail_setup_without_state_topic(self): + """Test for failing with no state topic.""" + with assert_setup_component(0) as config: + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt_alarm', + 'command_topic': 'alarm/command' + } + }) + assert not config[alarm_control_panel.DOMAIN] + + def test_fail_setup_without_command_topic(self): + """Test failing with no command topic.""" + with assert_setup_component(0): + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt_alarm', + 'state_topic': 'alarm/state' + } + }) + + def test_arm_home_no_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, CODE) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_arm_home_with_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, CODE, entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_arm_home_with_invalid_code(self): + """Attempt to arm home without a valid code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_arm_home(self.hass, CODE + '2') + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_arm_away_no_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + 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) + + def test_arm_away_with_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + 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) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + '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_arm_away_with_invalid_code(self): + """Attempt to arm away without a valid code.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + 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 + '2') + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_no_pending(self): + """Test triggering when no pending submitted method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 1, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + 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_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=60) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_pending(self): + """Test arm home method.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 3, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + 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_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_trigger_with_disarm_after_trigger(self): + """Test disarm after trigger.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': True, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + 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_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_disarm_while_pending_trigger(self): + """Test disarming while pending state.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'trigger_time': 5, + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_disarm(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_disarm_during_trigger_with_invalid_code(self): + """Test disarming while code is invalid.""" + self.assertTrue(setup_component( + self.hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 5, + 'code': CODE + '2', + 'disarm_after_trigger': False, + 'command_topic': 'alarm/command', + 'state_topic': 'alarm/state', + }})) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_disarm(self.hass, entity_id=entity_id) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_TRIGGERED, + self.hass.states.get(entity_id).state) + + def test_arm_home_via_command_topic(self): + """Test arming home via command topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'payload_arm_home': 'ARM_HOME', + } + }) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + # Fire the arm command via MQTT; ensure state changes to pending + fire_mqtt_message(self.hass, 'alarm/command', 'ARM_HOME') + self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_ARMED_HOME, + self.hass.states.get(entity_id).state) + + def test_arm_away_via_command_topic(self): + """Test arming away via command topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'payload_arm_away': 'ARM_AWAY', + } + }) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + # Fire the arm command via MQTT; ensure state changes to pending + fire_mqtt_message(self.hass, 'alarm/command', 'ARM_AWAY') + self.hass.block_till_done() + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + '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_pending_via_command_topic(self): + """Test disarming pending alarm via command topic.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'payload_disarm': 'DISARM', + } + }) + + entity_id = 'alarm_control_panel.test' + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + alarm_control_panel.alarm_trigger(self.hass) + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_PENDING, + self.hass.states.get(entity_id).state) + + # Now that we're pending, receive a command to disarm + fire_mqtt_message(self.hass, 'alarm/command', 'DISARM') + self.hass.block_till_done() + + self.assertEqual(STATE_ALARM_DISARMED, + self.hass.states.get(entity_id).state) + + def test_state_changes_are_published_to_mqtt(self): + """Test publishing of MQTT messages when state changes.""" + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'manual_mqtt', + 'name': 'test', + 'pending_time': 1, + 'trigger_time': 1, + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + } + }) + + # Component should send disarmed alarm state on startup + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), + self.mock_publish.mock_calls[-2][1]) + + # Arm in home mode + alarm_control_panel.alarm_arm_home(self.hass) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_ARMED_HOME, 0, True), + self.mock_publish.mock_calls[-2][1]) + + # Arm in away mode + alarm_control_panel.alarm_arm_away(self.hass) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_PENDING, 0, True), + self.mock_publish.mock_calls[-2][1]) + # Fast-forward a little bit + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual_mqtt.' + 'dt_util.utcnow'), return_value=future): + fire_time_changed(self.hass, future) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_ARMED_AWAY, 0, True), + self.mock_publish.mock_calls[-2][1]) + + # Disarm + alarm_control_panel.alarm_disarm(self.hass) + self.hass.block_till_done() + self.assertEqual(('alarm/state', STATE_ALARM_DISARMED, 0, True), + self.mock_publish.mock_calls[-2][1]) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index 28473511e29..2fd6c8415db 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -130,25 +130,6 @@ class TestAutomationState(unittest.TestCase): self.hass.block_till_done() self.assertEqual(1, len(self.calls)) - def test_if_fires_on_entity_change_with_state_filter(self): - """Test for firing on entity change with state filter.""" - assert setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: { - 'trigger': { - 'platform': 'state', - 'entity_id': 'test.entity', - 'state': 'world' - }, - 'action': { - 'service': 'test.automation' - } - } - }) - - self.hass.states.set('test.entity', 'world') - self.hass.block_till_done() - self.assertEqual(1, len(self.calls)) - def test_if_fires_on_entity_change_with_both_filters(self): """Test for firing if both filters are a non match.""" assert setup_component(self.hass, automation.DOMAIN, { diff --git a/tests/components/cover/test_demo.py b/tests/components/cover/test_demo.py index 83907de7708..9d26a6a4f4a 100644 --- a/tests/components/cover/test_demo.py +++ b/tests/components/cover/test_demo.py @@ -38,29 +38,37 @@ class TestCoverDemo(unittest.TestCase): def test_close_cover(self): """Test closing the cover.""" state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'open') self.assertEqual(70, state.attributes.get('current_position')) cover.close_cover(self.hass, ENTITY_COVER) self.hass.block_till_done() + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'closing') for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) fire_time_changed(self.hass, future) self.hass.block_till_done() state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'closed') self.assertEqual(0, state.attributes.get('current_position')) def test_open_cover(self): """Test opening the cover.""" state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'open') self.assertEqual(70, state.attributes.get('current_position')) cover.open_cover(self.hass, ENTITY_COVER) self.hass.block_till_done() + state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'opening') for _ in range(7): future = dt_util.utcnow() + timedelta(seconds=1) fire_time_changed(self.hass, future) self.hass.block_till_done() state = self.hass.states.get(ENTITY_COVER) + self.assertEqual(state.state, 'open') self.assertEqual(100, state.attributes.get('current_position')) def test_set_cover_position(self): diff --git a/tests/components/cover/test_zwave.py b/tests/components/cover/test_zwave.py index e1ee6075cea..fe9abe7172c 100644 --- a/tests/components/cover/test_zwave.py +++ b/tests/components/cover/test_zwave.py @@ -161,31 +161,46 @@ def test_roller_reverse_open_close(hass, mock_openzwave): def test_garage_value_changed(hass, mock_openzwave): """Test position changed.""" node = MockNode() - value = MockValue(data=False, node=node, + value = MockValue(data="Closed", node=node, command_class=const.COMMAND_CLASS_BARRIER_OPERATOR) values = MockEntityValues(primary=value, node=node) device = zwave.get_device(hass=hass, node=node, values=values, node_config={}) assert device.is_closed + assert not device.is_opening + assert not device.is_closing - value.data = True + value.data = "Opening" value_changed(value) - assert not device.is_closed + assert device.is_opening + assert not device.is_closing + + value.data = "Opened" + value_changed(value) + assert not device.is_closed + assert not device.is_opening + assert not device.is_closing + + value.data = "Closing" + value_changed(value) + assert not device.is_closed + assert not device.is_opening + assert device.is_closing def test_garage_commands(hass, mock_openzwave): """Test position changed.""" node = MockNode() - value = MockValue(data=False, node=node, + value = MockValue(data="Closed", node=node, command_class=const.COMMAND_CLASS_BARRIER_OPERATOR) values = MockEntityValues(primary=value, node=node) device = zwave.get_device(hass=hass, node=node, values=values, node_config={}) - assert value.data is False + assert value.data == "Closed" device.open_cover() - assert value.data is True + assert value.data == "Opened" device.close_cover() - assert value.data is False + assert value.data == "Closed" diff --git a/tests/components/sensor/test_google_wifi.py b/tests/components/sensor/test_google_wifi.py new file mode 100644 index 00000000000..978ec99236c --- /dev/null +++ b/tests/components/sensor/test_google_wifi.py @@ -0,0 +1,203 @@ +"""The tests for the Google Wifi platform.""" +import unittest +from unittest.mock import patch, Mock +from datetime import datetime, timedelta +import requests_mock + +from homeassistant import core as ha +from homeassistant.setup import setup_component +import homeassistant.components.sensor.google_wifi as google_wifi +from homeassistant.const import STATE_UNKNOWN +from homeassistant.util import dt as dt_util + +from tests.common import get_test_home_assistant, assert_setup_component + +NAME = 'foo' + +MOCK_DATA = ('{"software": {"softwareVersion":"initial",' + '"updateNewVersion":"initial"},' + '"system": {"uptime":86400},' + '"wan": {"localIpAddress":"initial", "online":true,' + '"ipAddress":true}}') + +MOCK_DATA_NEXT = ('{"software": {"softwareVersion":"next",' + '"updateNewVersion":"0.0.0.0"},' + '"system": {"uptime":172800},' + '"wan": {"localIpAddress":"next", "online":false,' + '"ipAddress":false}}') + + +class TestGoogleWifiSetup(unittest.TestCase): + """Tests for setting up the Google Wifi switch platform.""" + + 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() + + @requests_mock.Mocker() + def test_setup_minimum(self, mock_req): + """Test setup with minimum configuration.""" + resource = '{}{}{}'.format('http://', + google_wifi.DEFAULT_HOST, + google_wifi.ENDPOINT) + mock_req.get(resource, status_code=200) + self.assertTrue(setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'google_wifi' + } + })) + + @requests_mock.Mocker() + def test_setup_get(self, mock_req): + """Test setup with full configuration.""" + resource = '{}{}{}'.format('http://', + 'localhost', + google_wifi.ENDPOINT) + mock_req.get(resource, status_code=200) + self.assertTrue(setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'google_wifi', + 'host': 'localhost', + 'name': 'Test Wifi', + 'monitored_conditions': ['current_version', + 'new_version', + 'uptime', + 'last_restart', + 'local_ip', + 'status'] + } + })) + assert_setup_component(6, 'sensor') + + +class TestGoogleWifiSensor(unittest.TestCase): + """Tests for Google Wifi sensor platform.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + with requests_mock.Mocker() as mock_req: + self.setup_api(MOCK_DATA, mock_req) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def setup_api(self, data, mock_req): + """Setup API with fake data.""" + resource = '{}{}{}'.format('http://', + 'localhost', + google_wifi.ENDPOINT) + now = datetime(1970, month=1, day=1) + with patch('homeassistant.util.dt.now', return_value=now): + mock_req.get(resource, text=data, status_code=200) + self.api = google_wifi.GoogleWifiAPI("localhost") + self.name = NAME + self.sensor_dict = dict() + for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items(): + sensor = google_wifi.GoogleWifiSensor(self.hass, self.api, + self.name, condition) + name = '{}_{}'.format(self.name, condition) + units = cond_list[1] + icon = cond_list[2] + self.sensor_dict[condition] = {'sensor': sensor, + 'name': name, + 'units': units, + 'icon': icon} + + def fake_delay(self, ha_delay): + """Fake delay to prevent update throttle.""" + hass_now = dt_util.utcnow() + shifted_time = hass_now + timedelta(seconds=ha_delay) + self.hass.bus.fire(ha.EVENT_TIME_CHANGED, + {ha.ATTR_NOW: shifted_time}) + + def test_name(self): + """Test the name.""" + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + test_name = self.sensor_dict[name]['name'] + self.assertEqual(test_name, sensor.name) + + def test_unit_of_measurement(self): + """Test the unit of measurement.""" + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + self.assertEqual(self.sensor_dict[name]['units'], + sensor.unit_of_measurement) + + def test_icon(self): + """Test the icon.""" + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + self.assertEqual(self.sensor_dict[name]['icon'], sensor.icon) + + @requests_mock.Mocker() + def test_state(self, mock_req): + """Test the initial state.""" + self.setup_api(MOCK_DATA, mock_req) + now = datetime(1970, month=1, day=1) + with patch('homeassistant.util.dt.now', return_value=now): + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + self.fake_delay(2) + sensor.update() + if name == google_wifi.ATTR_LAST_RESTART: + self.assertEqual('1969-12-31 00:00:00', sensor.state) + elif name == google_wifi.ATTR_UPTIME: + self.assertEqual(1, sensor.state) + elif name == google_wifi.ATTR_STATUS: + self.assertEqual('Online', sensor.state) + else: + self.assertEqual('initial', sensor.state) + + @requests_mock.Mocker() + def test_update_when_value_is_none(self, mock_req): + """Test state gets updated to unknown when sensor returns no data.""" + self.setup_api(None, mock_req) + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + self.fake_delay(2) + sensor.update() + self.assertEqual(STATE_UNKNOWN, sensor.state) + + @requests_mock.Mocker() + def test_update_when_value_changed(self, mock_req): + """Test state gets updated when sensor returns a new status.""" + self.setup_api(MOCK_DATA_NEXT, mock_req) + now = datetime(1970, month=1, day=1) + with patch('homeassistant.util.dt.now', return_value=now): + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + self.fake_delay(2) + sensor.update() + if name == google_wifi.ATTR_LAST_RESTART: + self.assertEqual('1969-12-30 00:00:00', sensor.state) + elif name == google_wifi.ATTR_UPTIME: + self.assertEqual(2, sensor.state) + elif name == google_wifi.ATTR_STATUS: + self.assertEqual('Offline', sensor.state) + elif name == google_wifi.ATTR_NEW_VERSION: + self.assertEqual('Latest', sensor.state) + elif name == google_wifi.ATTR_LOCAL_IP: + self.assertEqual(STATE_UNKNOWN, sensor.state) + else: + self.assertEqual('next', sensor.state) + + def test_update_when_unavailiable(self): + """Test state updates when Google Wifi unavailiable.""" + self.api.update = Mock('google_wifi.GoogleWifiAPI.update', + side_effect=self.update_side_effect()) + for name in self.sensor_dict: + sensor = self.sensor_dict[name]['sensor'] + sensor.update() + self.assertEqual(STATE_UNKNOWN, sensor.state) + + def update_side_effect(self): + """Mock representation of update function.""" + self.api.data = None + self.api.availiable = False diff --git a/tests/components/sensor/test_uk_transport.py b/tests/components/sensor/test_uk_transport.py new file mode 100644 index 00000000000..b051d8e1a1b --- /dev/null +++ b/tests/components/sensor/test_uk_transport.py @@ -0,0 +1,93 @@ +"""The tests for the uk_transport platform.""" +import re + +import requests_mock +import unittest + +from homeassistant.components.sensor.uk_transport import ( + UkTransportSensor, + ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, ATTR_NEXT_BUSES, + ATTR_STATION_CODE, ATTR_CALLING_AT, ATTR_NEXT_TRAINS, + CONF_API_APP_KEY, CONF_API_APP_ID) +from homeassistant.setup import setup_component +from tests.common import load_fixture, get_test_home_assistant + +BUS_ATCOCODE = '340000368SHE' +BUS_DIRECTION = 'Wantage' +TRAIN_STATION_CODE = 'WIM' +TRAIN_DESTINATION_NAME = 'WAT' + +VALID_CONFIG = { + 'platform': 'uk_transport', + CONF_API_APP_ID: 'foo', + CONF_API_APP_KEY: 'ebcd1234', + 'queries': [{ + 'mode': 'bus', + 'origin': BUS_ATCOCODE, + 'destination': BUS_DIRECTION}, + { + 'mode': 'train', + 'origin': TRAIN_STATION_CODE, + 'destination': TRAIN_DESTINATION_NAME}] + } + + +class TestUkTransportSensor(unittest.TestCase): + """Test the uk_transport platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + def test_bus(self, mock_req): + """Test for operational uk_transport sensor with proper attributes.""" + with requests_mock.Mocker() as mock_req: + uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*') + mock_req.get(uri, text=load_fixture('uk_transport_bus.json')) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + + bus_state = self.hass.states.get('sensor.next_bus_to_wantage') + + assert type(bus_state.state) == str + assert bus_state.name == 'Next bus to {}'.format(BUS_DIRECTION) + assert bus_state.attributes.get(ATTR_ATCOCODE) == BUS_ATCOCODE + assert bus_state.attributes.get(ATTR_LOCALITY) == 'Harwell Campus' + assert bus_state.attributes.get(ATTR_STOP_NAME) == 'Bus Station' + assert len(bus_state.attributes.get(ATTR_NEXT_BUSES)) == 2 + + direction_re = re.compile(BUS_DIRECTION) + for bus in bus_state.attributes.get(ATTR_NEXT_BUSES): + print(bus['direction'], direction_re.match(bus['direction'])) + assert direction_re.search(bus['direction']) is not None + + @requests_mock.Mocker() + def test_train(self, mock_req): + """Test for operational uk_transport sensor with proper attributes.""" + with requests_mock.Mocker() as mock_req: + uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*') + mock_req.get(uri, text=load_fixture('uk_transport_train.json')) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': self.config})) + + train_state = self.hass.states.get('sensor.next_train_to_WAT') + + assert type(train_state.state) == str + assert train_state.name == 'Next train to {}'.format( + TRAIN_DESTINATION_NAME) + assert train_state.attributes.get( + ATTR_STATION_CODE) == TRAIN_STATION_CODE + assert train_state.attributes.get( + ATTR_CALLING_AT) == TRAIN_DESTINATION_NAME + assert len(train_state.attributes.get(ATTR_NEXT_TRAINS)) == 25 + + assert train_state.attributes.get( + ATTR_NEXT_TRAINS)[0]['destination_name'] == 'London Waterloo' + assert train_state.attributes.get( + ATTR_NEXT_TRAINS)[0]['estimated'] == '06:13' diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 8215eae26cc..133978a7bd8 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -2,7 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE +from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE,\ + ATTR_ASSUMED_STATE import homeassistant.components.switch as switch from tests.common import ( mock_mqtt_component, fire_mqtt_message, get_test_home_assistant) @@ -110,3 +111,45 @@ class TestSensorMQTT(unittest.TestCase): state = self.hass.states.get('switch.test') self.assertEqual(STATE_OFF, state.state) + + def test_controlling_availability(self): + """Test the controlling state via topic.""" + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0 + } + }) + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability_topic', '1') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'availability_topic', '0') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'state-topic', '1') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_UNAVAILABLE, state.state) + + fire_mqtt_message(self.hass, 'availability_topic', '1') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_ON, state.state) diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py index 47a3e086d29..eb8391c3e0d 100644 --- a/tests/components/test_alexa.py +++ b/tests/components/test_alexa.py @@ -47,10 +47,14 @@ def alexa_client(loop, hass, test_client): "uid": "uuid" } }, - "intents": { + } + })) + assert loop.run_until_complete(async_setup_component( + hass, 'intent_script', { + 'intent_script': { "WhereAreWeIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": """ {%- if is_state("device_tracker.paulus", "home") @@ -69,19 +73,19 @@ def alexa_client(loop, hass, test_client): }, "GetZodiacHoroscopeIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": "You told us your sign is {{ ZodiacSign }}.", } }, "AMAZON.PlaybackAction": { "speech": { - "type": "plaintext", + "type": "plain", "text": "Playing {{ object_byArtist_name }}.", } }, "CallServiceIntent": { "speech": { - "type": "plaintext", + "type": "plain", "text": "Service called", }, "action": { @@ -93,8 +97,7 @@ def alexa_client(loop, hass, test_client): } } } - } - })) + })) return loop.run_until_complete(test_client(hass.http.app)) diff --git a/tests/components/test_apiai.py b/tests/components/test_apiai.py index f5624f3c209..0c15326bbfc 100644 --- a/tests/components/test_apiai.py +++ b/tests/components/test_apiai.py @@ -57,14 +57,15 @@ def setUpModule(): hass.services.register("test", "apiai", mock_service) - setup.setup_component(hass, apiai.DOMAIN, { - # Key is here to verify we allow other keys in config too - "homeassistant": {}, - "apiai": { - "intents": { - "WhereAreWeIntent": { - "speech": - """ + assert setup.setup_component(hass, apiai.DOMAIN, { + "apiai": {}, + }) + assert setup.setup_component(hass, "intent_script", { + "intent_script": { + "WhereAreWeIntent": { + "speech": { + "type": "plain", + "text": """ {%- if is_state("device_tracker.paulus", "home") and is_state("device_tracker.anne_therese", "home") -%} @@ -77,19 +78,25 @@ def setUpModule(): }} {% endif %} """, + } + }, + "GetZodiacHoroscopeIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign }}.", + } + }, + "CallServiceIntent": { + "speech": { + "type": "plain", + "text": "Service called", }, - "GetZodiacHoroscopeIntent": { - "speech": "You told us your sign is {{ ZodiacSign }}.", - }, - "CallServiceIntent": { - "speech": "Service called", - "action": { - "service": "test.apiai", - "data_template": { - "hello": "{{ ZodiacSign }}" - }, - "entity_id": "switch.test", - } + "action": { + "service": "test.apiai", + "data_template": { + "hello": "{{ ZodiacSign }}" + }, + "entity_id": "switch.test", } } } @@ -509,5 +516,4 @@ class TestApiai(unittest.TestCase): self.assertEqual(200, req.status_code) text = req.json().get("speech") self.assertEqual( - "Intent 'unknown' is not yet configured within Home Assistant.", - text) + "This intent is not yet configured within Home Assistant.", text) diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 65ce95ee8e9..138ae1668f8 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -1,16 +1,18 @@ """The tests for the Conversation component.""" # pylint: disable=protected-access +import asyncio import unittest from unittest.mock import patch from homeassistant.core import callback -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component import homeassistant.components as core_components from homeassistant.components import conversation from homeassistant.const import ATTR_ENTITY_ID from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.helpers import intent -from tests.common import get_test_home_assistant, assert_setup_component +from tests.common import get_test_home_assistant, async_mock_intent class TestConversation(unittest.TestCase): @@ -25,10 +27,9 @@ class TestConversation(unittest.TestCase): self.assertTrue(run_coroutine_threadsafe( core_components.async_setup(self.hass, {}), self.hass.loop ).result()) - with assert_setup_component(0): - self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { - conversation.DOMAIN: {} - })) + self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { + conversation.DOMAIN: {} + })) # pylint: disable=invalid-name def tearDown(self): @@ -119,44 +120,131 @@ class TestConversation(unittest.TestCase): self.assertFalse(mock_call.called) -class TestConfiguration(unittest.TestCase): - """Test the conversation configuration component.""" +@asyncio.coroutine +def test_calling_intent(hass): + """Test calling an intent from a conversation.""" + intents = async_mock_intent(hass, 'OrderBeer') - # pylint: disable=invalid-name - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.assertTrue(setup_component(self.hass, conversation.DOMAIN, { - conversation.DOMAIN: { - 'test_2': { - 'sentence': 'switch boolean', - 'action': { - 'service': 'input_boolean.toggle' - } - } + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] } - })) + } + }) + assert result - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'I would like the Grolsch beer' + }) + yield from hass.async_block_till_done() - def test_custom(self): - """Setup and perform good turn on requests.""" - calls = [] + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'I would like the Grolsch beer' - @callback - def record_call(service): - """Recorder for a call.""" - calls.append(service) - self.hass.services.register('input_boolean', 'toggle', record_call) +@asyncio.coroutine +def test_register_before_setup(hass): + """Test calling an intent from a conversation.""" + intents = async_mock_intent(hass, 'OrderBeer') - event_data = {conversation.ATTR_TEXT: 'switch boolean'} - self.assertTrue(self.hass.services.call( - conversation.DOMAIN, 'process', event_data, True)) + hass.components.conversation.async_register('OrderBeer', [ + 'A {type} beer, please' + ]) - call = calls[-1] - self.assertEqual('input_boolean', call.domain) - self.assertEqual('toggle', call.service) + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] + } + } + }) + assert result + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'A Grolsch beer, please' + }) + yield from hass.async_block_till_done() + + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'A Grolsch beer, please' + + yield from hass.services.async_call( + 'conversation', 'process', { + conversation.ATTR_TEXT: 'I would like the Grolsch beer' + }) + yield from hass.async_block_till_done() + + assert len(intents) == 2 + intent = intents[1] + assert intent.platform == 'conversation' + assert intent.intent_type == 'OrderBeer' + assert intent.slots == {'type': {'value': 'Grolsch'}} + assert intent.text_input == 'I would like the Grolsch beer' + + +@asyncio.coroutine +def test_http_processing_intent(hass, test_client): + """Test processing intent via HTTP API.""" + class TestIntentHandler(intent.IntentHandler): + intent_type = 'OrderBeer' + + @asyncio.coroutine + def async_handle(self, intent): + """Handle the intent.""" + response = intent.create_response() + response.async_set_speech( + "I've ordered a {}!".format(intent.slots['type']['value'])) + response.async_set_card( + "Beer ordered", + "You chose a {}.".format(intent.slots['type']['value'])) + return response + + intent.async_register(hass, TestIntentHandler()) + + result = yield from async_setup_component(hass, 'conversation', { + 'conversation': { + 'intents': { + 'OrderBeer': [ + 'I would like the {type} beer' + ] + } + } + }) + assert result + + client = yield from test_client(hass.http.app) + resp = yield from client.post('/api/conversation/process', json={ + 'text': 'I would like the Grolsch beer' + }) + + assert resp.status == 200 + data = yield from resp.json() + + assert data == { + 'card': { + 'simple': { + 'content': 'You chose a Grolsch.', + 'title': 'Beer ordered' + }}, + 'speech': { + 'plain': { + 'extra_data': None, + 'speech': "I've ordered a Grolsch!" + } + } + } diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 1bb39b053a5..93aac65ecb5 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -1,52 +1,62 @@ """The tests for the Demo component.""" +import asyncio import json import os -import unittest -from homeassistant.setup import setup_component +import pytest + +from homeassistant.setup import async_setup_component from homeassistant.components import demo, device_tracker from homeassistant.remote import JSONEncoder -from tests.common import mock_http_component, get_test_home_assistant + +@pytest.fixture +def minimize_demo_platforms(hass): + """Cleanup demo component for tests.""" + orig = demo.COMPONENTS_WITH_DEMO_PLATFORM + demo.COMPONENTS_WITH_DEMO_PLATFORM = [ + 'switch', 'light', 'media_player'] + + yield + + demo.COMPONENTS_WITH_DEMO_PLATFORM = orig -class TestDemo(unittest.TestCase): - """Test the Demo component.""" +@pytest.fixture(autouse=True) +def demo_cleanup(hass): + """Clean up device tracker demo file.""" + yield + try: + os.remove(hass.config.path(device_tracker.YAML_DEVICES)) + except FileNotFoundError: + pass - def setUp(self): # pylint: disable=invalid-name - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_http_component(self.hass) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() +@asyncio.coroutine +def test_if_demo_state_shows_by_default(hass, minimize_demo_platforms): + """Test if demo state shows if we give no configuration.""" + yield from async_setup_component(hass, demo.DOMAIN, {demo.DOMAIN: {}}) - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass + assert hass.states.get('a.Demo_Mode') is not None - def test_if_demo_state_shows_by_default(self): - """Test if demo state shows if we give no configuration.""" - setup_component(self.hass, demo.DOMAIN, {demo.DOMAIN: {}}) - self.assertIsNotNone(self.hass.states.get('a.Demo_Mode')) +@asyncio.coroutine +def test_hiding_demo_state(hass, minimize_demo_platforms): + """Test if you can hide the demo card.""" + yield from async_setup_component(hass, demo.DOMAIN, { + demo.DOMAIN: {'hide_demo_state': 1}}) - def test_hiding_demo_state(self): - """Test if you can hide the demo card.""" - setup_component(self.hass, demo.DOMAIN, { - demo.DOMAIN: {'hide_demo_state': 1}}) + assert hass.states.get('a.Demo_Mode') is None - self.assertIsNone(self.hass.states.get('a.Demo_Mode')) - def test_all_entities_can_be_loaded_over_json(self): - """Test if you can hide the demo card.""" - setup_component(self.hass, demo.DOMAIN, { - demo.DOMAIN: {'hide_demo_state': 1}}) +@asyncio.coroutine +def test_all_entities_can_be_loaded_over_json(hass): + """Test if you can hide the demo card.""" + yield from async_setup_component(hass, demo.DOMAIN, { + demo.DOMAIN: {'hide_demo_state': 1}}) - try: - json.dumps(self.hass.states.all(), cls=JSONEncoder) - except Exception: - self.fail('Unable to convert all demo entities to JSON. ' - 'Wrong data in state machine!') + try: + json.dumps(hass.states.async_all(), cls=JSONEncoder) + except Exception: + pytest.fail('Unable to convert all demo entities to JSON. ' + 'Wrong data in state machine!') diff --git a/tests/components/test_hassio.py b/tests/components/test_hassio.py index eb0754fdc0a..ccb56891495 100644 --- a/tests/components/test_hassio.py +++ b/tests/components/test_hassio.py @@ -106,6 +106,28 @@ def test_forward_request_no_auth_for_panel(hassio_client): assert mresp.mock_calls[0][1] == (response, 'data') +@asyncio.coroutine +def test_forward_request_no_auth_for_logo(hassio_client): + """Test no auth needed for .""" + response = MagicMock() + response.read.return_value = mock_coro('data') + + with patch('homeassistant.components.hassio.HassIO.command_proxy', + Mock(return_value=mock_coro(response))), \ + patch('homeassistant.components.hassio._create_response') as mresp: + mresp.return_value = 'response' + resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo') + + # Check we got right response + assert resp.status == 200 + body = yield from resp.text() + assert body == 'response' + + # Check we forwarded command + assert len(mresp.mock_calls) == 1 + assert mresp.mock_calls[0][1] == (response, 'data') + + @asyncio.coroutine def test_forward_log_request(hassio_client): """Test fetching normal log path.""" diff --git a/tests/components/test_intent_script.py b/tests/components/test_intent_script.py new file mode 100644 index 00000000000..2c7a03e645a --- /dev/null +++ b/tests/components/test_intent_script.py @@ -0,0 +1,45 @@ +"""Test intent_script component.""" +import asyncio + +from homeassistant.bootstrap import async_setup_component +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +@asyncio.coroutine +def test_intent_script(hass): + """Test intent scripts work.""" + calls = async_mock_service(hass, 'test', 'service') + + yield from async_setup_component(hass, 'intent_script', { + 'intent_script': { + 'HelloWorld': { + 'action': { + 'service': 'test.service', + 'data_template': { + 'hello': '{{ name }}' + } + }, + 'card': { + 'title': 'Hello {{ name }}', + 'content': 'Content for {{ name }}', + }, + 'speech': { + 'text': 'Good morning {{ name }}' + } + } + } + }) + + response = yield from intent.async_handle( + hass, 'test', 'HelloWorld', {'name': {'value': 'Paulus'}} + ) + + assert len(calls) == 1 + assert calls[0].data['hello'] == 'Paulus' + + assert response.speech['plain']['speech'] == 'Good morning Paulus' + + assert response.card['simple']['title'] == 'Hello Paulus' + assert response.card['simple']['content'] == 'Content for Paulus' diff --git a/tests/components/test_python_script.py b/tests/components/test_python_script.py index 78142e06cf3..62c1b67eba9 100644 --- a/tests/components/test_python_script.py +++ b/tests/components/test_python_script.py @@ -137,6 +137,22 @@ hass.async_stop() assert "Not allowed to access async methods" in caplog.text +@asyncio.coroutine +def test_using_complex_structures(hass, caplog): + """Test that dicts and lists work.""" + caplog.set_level(logging.INFO) + source = """ +mydict = {"a": 1, "b": 2} +mylist = [1, 2, 3, 4] +logger.info('Logging from inside script: %s %s' % (mydict["a"], mylist[2])) + """ + + hass.async_add_job(execute, hass, 'test.py', source, {}) + yield from hass.async_block_till_done() + + assert "Logging from inside script: 1 3" in caplog.text + + @asyncio.coroutine def test_accessing_forbidden_methods(hass, caplog): """Test compile error logs error.""" diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py new file mode 100644 index 00000000000..449eab65016 --- /dev/null +++ b/tests/components/test_shopping_list.py @@ -0,0 +1,194 @@ +"""Test shopping list component.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.bootstrap import async_setup_component +from homeassistant.helpers import intent + + +@pytest.fixture(autouse=True) +def mock_shopping_list_save(): + """Stub out the persistence.""" + with patch('homeassistant.components.shopping_list.ShoppingData.save'): + yield + + +@asyncio.coroutine +def test_add_item(hass): + """Test adding an item intent.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + response = yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + + assert response.speech['plain']['speech'] == \ + "I've added beer to your shopping list" + + +@asyncio.coroutine +def test_recent_items_intent(hass): + """Test recent items.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'soda'}} + ) + + response = yield from intent.async_handle( + hass, 'test', 'HassShoppingListLastItems' + ) + + assert response.speech['plain']['speech'] == \ + "These are the top 3 items on your shopping list: soda, wine, beer" + + +@asyncio.coroutine +def test_api_get_all(hass, test_client): + """Test the API.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + client = yield from test_client(hass.http.app) + resp = yield from client.get('/api/shopping_list') + + assert resp.status == 200 + data = yield from resp.json() + assert len(data) == 2 + assert data[0]['name'] == 'beer' + assert not data[0]['complete'] + assert data[1]['name'] == 'wine' + assert not data[1]['complete'] + + +@asyncio.coroutine +def test_api_update(hass, test_client): + """Test the API.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + + client = yield from test_client(hass.http.app) + resp = yield from client.post( + '/api/shopping_list/item/{}'.format(beer_id), json={ + 'name': 'soda' + }) + + assert resp.status == 200 + data = yield from resp.json() + assert data == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + + resp = yield from client.post( + '/api/shopping_list/item/{}'.format(wine_id), json={ + 'complete': True + }) + + assert resp.status == 200 + data = yield from resp.json() + assert data == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + beer, wine = hass.data['shopping_list'].items + assert beer == { + 'id': beer_id, + 'name': 'soda', + 'complete': False + } + assert wine == { + 'id': wine_id, + 'name': 'wine', + 'complete': True + } + + +@asyncio.coroutine +def test_api_update_fails(hass, test_client): + """Test the API.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + + client = yield from test_client(hass.http.app) + resp = yield from client.post( + '/api/shopping_list/non_existing', json={ + 'name': 'soda' + }) + + assert resp.status == 404 + + beer_id = hass.data['shopping_list'].items[0]['id'] + client = yield from test_client(hass.http.app) + resp = yield from client.post( + '/api/shopping_list/item/{}'.format(beer_id), json={ + 'name': 123, + }) + + assert resp.status == 400 + + +@asyncio.coroutine +def test_api_clear_completed(hass, test_client): + """Test the API.""" + yield from async_setup_component(hass, 'shopping_list', {}) + + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + yield from intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + + client = yield from test_client(hass.http.app) + + # Mark beer as completed + resp = yield from client.post( + '/api/shopping_list/item/{}'.format(beer_id), json={ + 'complete': True + }) + assert resp.status == 200 + + resp = yield from client.post('/api/shopping_list/clear_completed') + assert resp.status == 200 + + items = hass.data['shopping_list'].items + assert len(items) == 1 + + assert items[0] == { + 'id': wine_id, + 'name': 'wine', + 'complete': False + } diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 5687723e17a..5e49bbd0382 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -2,7 +2,7 @@ import asyncio from homeassistant.bootstrap import async_setup_component -from tests.common import async_fire_mqtt_message, async_mock_service +from tests.common import async_fire_mqtt_message, async_mock_intent EXAMPLE_MSG = """ { @@ -16,7 +16,7 @@ EXAMPLE_MSG = """ "slotName": "light_color", "value": { "kind": "Custom", - "value": "blue" + "value": "green" } } ] @@ -27,27 +27,19 @@ EXAMPLE_MSG = """ @asyncio.coroutine def test_snips_call_action(hass, mqtt_mock): """Test calling action via Snips.""" - calls = async_mock_service(hass, 'test', 'service') - result = yield from async_setup_component(hass, "snips", { - "snips": { - "intents": { - "Lights": { - "action": { - "service": "test.service", - "data_template": { - "color": "{{ light_color }}" - } - } - } - } - } + "snips": {}, }) assert result + intents = async_mock_intent(hass, 'Lights') + async_fire_mqtt_message(hass, 'hermes/nlu/intentParsed', EXAMPLE_MSG) yield from hass.async_block_till_done() - assert len(calls) == 1 - call = calls[0] - assert call.data.get('color') == 'blue' + assert len(intents) == 1 + intent = intents[0] + assert intent.platform == 'snips' + assert intent.intent_type == 'Lights' + assert intent.slots == {'light_color': {'value': 'green'}} + assert intent.text_input == 'turn the lights green' diff --git a/tests/components/test_statsd.py b/tests/components/test_statsd.py index eb8933b77be..5fd907fe0b1 100644 --- a/tests/components/test_statsd.py +++ b/tests/components/test_statsd.py @@ -86,6 +86,7 @@ class TestStatsd(unittest.TestCase): config = { 'statsd': { 'host': 'host', + 'value_mapping': {'custom': 3} } } @@ -98,6 +99,7 @@ class TestStatsd(unittest.TestCase): valid = {'1': 1, '1.0': 1.0, + 'custom': 3, STATE_ON: 1, STATE_OFF: 0} for in_, out in valid.items(): diff --git a/tests/fixtures/uk_transport_bus.json b/tests/fixtures/uk_transport_bus.json new file mode 100644 index 00000000000..5e1e27a4ba3 --- /dev/null +++ b/tests/fixtures/uk_transport_bus.json @@ -0,0 +1,110 @@ +{ + "atcocode": "340000368SHE", + "bearing": "", + "departures": { + "32A": [{ + "aimed_departure_time": "10:18", + "best_departure_estimate": "10:18", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Market Place (Wantage)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/10:18/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "11:00", + "best_departure_estimate": "11:00", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Stratton Way (Abingdon Town Centre)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:00/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "11:18", + "best_departure_estimate": "11:18", + "date": "2017-05-09", + "dir": "outbound", + "direction": "Market Place (Wantage)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:18/timetable.json?app_id=e99&app_key=058", + "line": "32A", + "line_name": "32A", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + } + ], + "X32": [{ + "aimed_departure_time": "10:09", + "best_departure_estimate": "10:09", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parkway Station (Didcot)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:09/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "10:30", + "best_departure_estimate": "10:30", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parks Road (Oxford City Centre)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:30/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + }, + { + "aimed_departure_time": "10:39", + "best_departure_estimate": "10:39", + "date": "2017-05-09", + "dir": "inbound", + "direction": "Parkway Station (Didcot)", + "expected_departure_date": null, + "expected_departure_time": null, + "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:39/timetable.json?app_id=e99&app_key=058", + "line": "X32", + "line_name": "X32", + "mode": "bus", + "operator": "THTR", + "operator_name": "Thames Travel", + "source": "Traveline timetable (not a nextbuses live region)" + } + ] + }, + "indicator": "in", + "locality": "Harwell Campus", + "name": "Bus Station (in)", + "request_time": "2017-05-09T10:03:41+01:00", + "smscode": "oxfajwgp", + "stop_name": "Bus Station" +} diff --git a/tests/fixtures/uk_transport_train.json b/tests/fixtures/uk_transport_train.json new file mode 100644 index 00000000000..b06e8db6ca7 --- /dev/null +++ b/tests/fixtures/uk_transport_train.json @@ -0,0 +1,511 @@ +{ + "date": "2017-07-10", + "time_of_day": "06:10", + "request_time": "2017-07-10T06:10:05+01:00", + "station_name": "Wimbledon", + "station_code": "WIM", + "departures": { + "all": [ + { + "mode": "train", + "service": "24671405", + "train_uid": "W36814", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:13", + "aimed_arrival_time": null, + "aimed_pass_time": null, + "origin_name": "Wimbledon", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "STARTS HERE", + "expected_arrival_time": null, + "expected_departure_time": "06:13", + "best_arrival_estimate_mins": null, + "best_departure_estimate_mins": 2 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36613", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:14", + "aimed_arrival_time": "06:13", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "06:13", + "expected_departure_time": "06:14", + "best_arrival_estimate_mins": 2, + "best_departure_estimate_mins": 3 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36012", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:20", + "aimed_arrival_time": "06:20", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:20", + "expected_departure_time": "06:20", + "best_arrival_estimate_mins": 9, + "best_departure_estimate_mins": 9 + }, + { + "mode": "train", + "service": "24673305", + "train_uid": "W34087", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:23", + "aimed_arrival_time": "06:23", + "aimed_pass_time": null, + "origin_name": "Dorking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "XX", + "status": "ON TIME", + "expected_arrival_time": "06:23", + "expected_departure_time": "06:23", + "best_arrival_estimate_mins": 12, + "best_departure_estimate_mins": 12 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37471", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:32", + "aimed_arrival_time": "06:31", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:31", + "expected_departure_time": "06:32", + "best_arrival_estimate_mins": 20, + "best_departure_estimate_mins": 21 + }, + { + "mode": "train", + "service": "24673605", + "train_uid": "W35790", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:35", + "aimed_arrival_time": "06:35", + "aimed_pass_time": null, + "origin_name": "Woking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:35", + "expected_departure_time": "06:35", + "best_arrival_estimate_mins": 24, + "best_departure_estimate_mins": 24 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35665", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:38", + "aimed_arrival_time": "06:38", + "aimed_pass_time": null, + "origin_name": "Epsom", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:38", + "expected_departure_time": "06:38", + "best_arrival_estimate_mins": 27, + "best_departure_estimate_mins": 27 + }, + { + "mode": "train", + "service": "24671405", + "train_uid": "W36816", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:43", + "aimed_arrival_time": "06:43", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:43", + "expected_departure_time": "06:43", + "best_arrival_estimate_mins": 32, + "best_departure_estimate_mins": 32 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36618", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:44", + "aimed_arrival_time": "06:43", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:43", + "expected_departure_time": "06:44", + "best_arrival_estimate_mins": 32, + "best_departure_estimate_mins": 33 + }, + { + "mode": "train", + "service": "24673105", + "train_uid": "W36429", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:47", + "aimed_arrival_time": "06:46", + "aimed_pass_time": null, + "origin_name": "Shepperton", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:46", + "expected_departure_time": "06:47", + "best_arrival_estimate_mins": 35, + "best_departure_estimate_mins": 36 + }, + { + "mode": "train", + "service": "24629204", + "train_uid": "W36916", + "platform": "6", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:47", + "aimed_arrival_time": "06:47", + "aimed_pass_time": null, + "origin_name": "Basingstoke", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "LATE", + "expected_arrival_time": "06:48", + "expected_departure_time": "06:48", + "best_arrival_estimate_mins": 37, + "best_departure_estimate_mins": 37 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36016", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:50", + "aimed_arrival_time": "06:49", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:49", + "expected_departure_time": "06:50", + "best_arrival_estimate_mins": 38, + "best_departure_estimate_mins": 39 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35489", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:53", + "aimed_arrival_time": "06:52", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "06:52", + "expected_departure_time": "06:53", + "best_arrival_estimate_mins": 41, + "best_departure_estimate_mins": 42 + }, + { + "mode": "train", + "service": "24673405", + "train_uid": "W37107", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "06:58", + "aimed_arrival_time": "06:57", + "aimed_pass_time": null, + "origin_name": "Chessington South", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "06:57", + "expected_departure_time": "06:58", + "best_arrival_estimate_mins": 46, + "best_departure_estimate_mins": 47 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37473", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:02", + "aimed_arrival_time": "07:01", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "EARLY", + "expected_arrival_time": "07:01", + "expected_departure_time": "07:02", + "best_arrival_estimate_mins": 50, + "best_departure_estimate_mins": 51 + }, + { + "mode": "train", + "service": "24673605", + "train_uid": "W35795", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:05", + "aimed_arrival_time": "07:04", + "aimed_pass_time": null, + "origin_name": "Woking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:04", + "expected_departure_time": "07:05", + "best_arrival_estimate_mins": 53, + "best_departure_estimate_mins": 54 + }, + { + "mode": "train", + "service": "24673305", + "train_uid": "W34090", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:08", + "aimed_arrival_time": "07:07", + "aimed_pass_time": null, + "origin_name": "Dorking", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "XX", + "status": "ON TIME", + "expected_arrival_time": "07:07", + "expected_departure_time": "07:08", + "best_arrival_estimate_mins": 56, + "best_departure_estimate_mins": 57 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36623", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:13", + "aimed_arrival_time": "07:12", + "aimed_pass_time": null, + "origin_name": "Hampton Court", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:12", + "expected_departure_time": "07:13", + "best_arrival_estimate_mins": 61, + "best_departure_estimate_mins": 62 + }, + { + "mode": "train", + "service": "24671405", + "train_uid": "W36819", + "platform": "8", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:13", + "aimed_arrival_time": "07:13", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:13", + "expected_departure_time": "07:13", + "best_arrival_estimate_mins": 62, + "best_departure_estimate_mins": 62 + }, + { + "mode": "train", + "service": "24673105", + "train_uid": "W36434", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:16", + "aimed_arrival_time": "07:15", + "aimed_pass_time": null, + "origin_name": "Shepperton", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:15", + "expected_departure_time": "07:16", + "best_arrival_estimate_mins": 64, + "best_departure_estimate_mins": 65 + }, + { + "mode": "train", + "service": "24673505", + "train_uid": "W36019", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:19", + "aimed_arrival_time": "07:18", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:18", + "expected_departure_time": "07:19", + "best_arrival_estimate_mins": 67, + "best_departure_estimate_mins": 68 + }, + { + "mode": "train", + "service": "24673705", + "train_uid": "W35494", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:22", + "aimed_arrival_time": "07:21", + "aimed_pass_time": null, + "origin_name": "Guildford", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:21", + "expected_departure_time": "07:22", + "best_arrival_estimate_mins": 70, + "best_departure_estimate_mins": 71 + }, + { + "mode": "train", + "service": "24673205", + "train_uid": "W36810", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:25", + "aimed_arrival_time": "07:24", + "aimed_pass_time": null, + "origin_name": "Esher", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:24", + "expected_departure_time": "07:25", + "best_arrival_estimate_mins": 73, + "best_departure_estimate_mins": 74 + }, + { + "mode": "train", + "service": "24673405", + "train_uid": "W37112", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:28", + "aimed_arrival_time": "07:27", + "aimed_pass_time": null, + "origin_name": "Chessington South", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:27", + "expected_departure_time": "07:28", + "best_arrival_estimate_mins": 76, + "best_departure_estimate_mins": 77 + }, + { + "mode": "train", + "service": "24671505", + "train_uid": "W37476", + "platform": "5", + "operator": "SW", + "operator_name": "South West Trains", + "aimed_departure_time": "07:32", + "aimed_arrival_time": "07:31", + "aimed_pass_time": null, + "origin_name": "London Waterloo", + "source": "Network Rail", + "destination_name": "London Waterloo", + "category": "OO", + "status": "ON TIME", + "expected_arrival_time": "07:31", + "expected_departure_time": "07:32", + "best_arrival_estimate_mins": 80, + "best_departure_estimate_mins": 81 + } + ] + } +} diff --git a/tests/test_config.py b/tests/test_config.py index 00b631a2f78..8c889979a82 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -260,40 +260,30 @@ class TestConfig(unittest.TestCase): @mock.patch('homeassistant.config.shutil') @mock.patch('homeassistant.config.os') def test_remove_lib_on_upgrade(self, mock_os, mock_shutil): - """Test removal of library on upgrade.""" - ha_version = '0.7.0' - + """Test removal of library on upgrade from before 0.50.""" + ha_version = '0.49.0' mock_os.path.isdir = mock.Mock(return_value=True) - mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): opened_file = mock_open.return_value # pylint: disable=no-member opened_file.readline.return_value = ha_version - self.hass.config.path = mock.Mock() - config_util.process_ha_config_upgrade(self.hass) - hass_path = self.hass.config.path.return_value self.assertEqual(mock_os.path.isdir.call_count, 1) self.assertEqual( mock_os.path.isdir.call_args, mock.call(hass_path) ) - self.assertEqual(mock_shutil.rmtree.call_count, 1) self.assertEqual( mock_shutil.rmtree.call_args, mock.call(hass_path) ) - @mock.patch('homeassistant.config.shutil') - @mock.patch('homeassistant.config.os') - def test_not_remove_lib_if_not_upgrade(self, mock_os, mock_shutil): - """Test removal of library with no upgrade.""" - ha_version = __version__ - - mock_os.path.isdir = mock.Mock(return_value=True) + def test_process_config_upgrade(self): + """Test update of version on upgrade.""" + ha_version = '0.8.0' mock_open = mock.mock_open() with mock.patch('homeassistant.config.open', mock_open, create=True): @@ -301,12 +291,38 @@ class TestConfig(unittest.TestCase): # pylint: disable=no-member opened_file.readline.return_value = ha_version - self.hass.config.path = mock.Mock() + config_util.process_ha_config_upgrade(self.hass) + + self.assertEqual(opened_file.write.call_count, 1) + self.assertEqual( + opened_file.write.call_args, mock.call(__version__) + ) + + def test_config_upgrade_same_version(self): + """Test no update of version on no upgrade.""" + ha_version = __version__ + + mock_open = mock.mock_open() + with mock.patch('homeassistant.config.open', mock_open, create=True): + opened_file = mock_open.return_value + # pylint: disable=no-member + opened_file.readline.return_value = ha_version config_util.process_ha_config_upgrade(self.hass) - assert mock_os.path.isdir.call_count == 0 - assert mock_shutil.rmtree.call_count == 0 + assert opened_file.write.call_count == 0 + + def test_config_upgrade_no_file(self): + """Test update of version on upgrade, with no version file.""" + mock_open = mock.mock_open() + mock_open.side_effect = [FileNotFoundError(), mock.DEFAULT] + with mock.patch('homeassistant.config.open', mock_open, create=True): + opened_file = mock_open.return_value + # pylint: disable=no-member + config_util.process_ha_config_upgrade(self.hass) + self.assertEqual(opened_file.write.call_count, 1) + self.assertEqual( + opened_file.write.call_args, mock.call(__version__)) @mock.patch('homeassistant.config.shutil') @mock.patch('homeassistant.config.os') diff --git a/tests/test_loader.py b/tests/test_loader.py index 0b3f9653faa..6081b061ed2 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,11 +1,15 @@ """Test to verify that we can load components.""" # pylint: disable=protected-access +import asyncio import unittest +import pytest + import homeassistant.loader as loader import homeassistant.components.http as http -from tests.common import get_test_home_assistant, MockModule +from tests.common import ( + get_test_home_assistant, MockModule, async_mock_service) class TestLoader(unittest.TestCase): @@ -54,3 +58,29 @@ class TestLoader(unittest.TestCase): # Try to get load order for non-existing component self.assertEqual([], loader.load_order_component('mod1')) + + +def test_component_loader(hass): + """Test loading components.""" + components = loader.Components(hass) + assert components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA + assert hass.components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA + + +def test_component_loader_non_existing(hass): + """Test loading components.""" + components = loader.Components(hass) + with pytest.raises(ImportError): + components.non_existing + + +@asyncio.coroutine +def test_component_wrapper(hass): + """Test component wrapper.""" + calls = async_mock_service(hass, 'light', 'turn_on') + + components = loader.Components(hass) + components.light.async_turn_on('light.test') + yield from hass.async_block_till_done() + + assert len(calls) == 1 diff --git a/tests/test_setup.py b/tests/test_setup.py index 291dfdd741f..9a0f85874ad 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,7 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONSTRAINT_FILE import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -203,6 +203,43 @@ class TestSetup: assert not setup.setup_component(self.hass, 'comp') assert 'comp' not in self.hass.config.components + @mock.patch('homeassistant.setup.os.path.dirname') + @mock.patch('homeassistant.util.package.running_under_virtualenv', + return_value=True) + @mock.patch('homeassistant.util.package.install_package', + return_value=True) + def test_requirement_installed_in_venv( + self, mock_install, mock_venv, mock_dirname): + """Test requirement installed in virtual environment.""" + mock_venv.return_value = True + mock_dirname.return_value = 'ha_package_path' + self.hass.config.skip_pip = False + loader.set_component( + 'comp', MockModule('comp', requirements=['package==0.0.1'])) + assert setup.setup_component(self.hass, 'comp') + assert 'comp' in self.hass.config.components + assert mock_install.call_args == mock.call( + 'package==0.0.1', + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + + @mock.patch('homeassistant.setup.os.path.dirname') + @mock.patch('homeassistant.util.package.running_under_virtualenv', + return_value=False) + @mock.patch('homeassistant.util.package.install_package', + return_value=True) + def test_requirement_installed_in_deps( + self, mock_install, mock_venv, mock_dirname): + """Test requirement installed in deps directory.""" + mock_dirname.return_value = 'ha_package_path' + self.hass.config.skip_pip = False + loader.set_component( + 'comp', MockModule('comp', requirements=['package==0.0.1'])) + assert setup.setup_component(self.hass, 'comp') + assert 'comp' in self.hass.config.components + assert mock_install.call_args == mock.call( + 'package==0.0.1', target=self.hass.config.path('deps'), + constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) + def test_component_not_setup_twice_if_loaded_during_other_setup(self): """Test component setup while waiting for lock is not setup twice.""" result = [] diff --git a/tests/util/test_package.py b/tests/util/test_package.py index e0682d79f57..ade374dad33 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -1,11 +1,13 @@ """Test Home Assistant package util methods.""" +import asyncio +import logging import os -import pkg_resources -import unittest - +import sys from subprocess import PIPE -from distutils.sysconfig import get_python_lib -from unittest.mock import call, patch, Mock +from unittest.mock import MagicMock, call, patch + +import pkg_resources +import pytest import homeassistant.util.package as package @@ -18,124 +20,212 @@ TEST_ZIP_REQ = 'file://{}#{}' \ .format(os.path.join(RESOURCE_DIR, 'pyhelloworld3.zip'), TEST_NEW_REQ) -@patch('homeassistant.util.package.Popen') -@patch('homeassistant.util.package.check_package_exists') -class TestPackageUtilInstallPackage(unittest.TestCase): - """Test for homeassistant.util.package module.""" - - def setUp(self): - """Setup the tests.""" - self.mock_process = Mock() - self.mock_process.communicate.return_value = (b'message', b'error') - self.mock_process.returncode = 0 - - def test_install_existing_package(self, mock_exists, mock_popen): - """Test an install attempt on an existing package.""" - mock_popen.return_value = self.mock_process - mock_exists.return_value = True - - self.assertTrue(package.install_package(TEST_EXIST_REQ)) - - self.assertEqual(mock_exists.call_count, 1) - self.assertEqual(mock_exists.call_args, call(TEST_EXIST_REQ, None)) - - self.assertEqual(self.mock_process.communicate.call_count, 0) - - @patch('homeassistant.util.package.sys') - def test_install(self, mock_sys, mock_exists, mock_popen): - """Test an install attempt on a package that doesn't exist.""" - mock_exists.return_value = False - mock_popen.return_value = self.mock_process - - self.assertTrue(package.install_package(TEST_NEW_REQ, False)) - - self.assertEqual(mock_exists.call_count, 1) - - self.assertEqual(self.mock_process.communicate.call_count, 1) - self.assertEqual(mock_popen.call_count, 1) - self.assertEqual( - mock_popen.call_args, - call([ - mock_sys.executable, '-m', 'pip', 'install', '--quiet', - TEST_NEW_REQ - ], stdin=PIPE, stdout=PIPE, stderr=PIPE) - ) - - @patch('homeassistant.util.package.sys') - def test_install_upgrade(self, mock_sys, mock_exists, mock_popen): - """Test an upgrade attempt on a package.""" - mock_exists.return_value = False - mock_popen.return_value = self.mock_process - - self.assertTrue(package.install_package(TEST_NEW_REQ)) - - self.assertEqual(mock_exists.call_count, 1) - - self.assertEqual(self.mock_process.communicate.call_count, 1) - self.assertEqual(mock_popen.call_count, 1) - self.assertEqual( - mock_popen.call_args, - call([ - mock_sys.executable, '-m', 'pip', 'install', '--quiet', - TEST_NEW_REQ, '--upgrade' - ], stdin=PIPE, stdout=PIPE, stderr=PIPE) - ) - - @patch('homeassistant.util.package.sys') - def test_install_target(self, mock_sys, mock_exists, mock_popen): - """Test an install with a target.""" - target = 'target_folder' - mock_exists.return_value = False - mock_popen.return_value = self.mock_process - - self.assertTrue( - package.install_package(TEST_NEW_REQ, False, target=target) - ) - - self.assertEqual(mock_exists.call_count, 1) - - self.assertEqual(self.mock_process.communicate.call_count, 1) - self.assertEqual(mock_popen.call_count, 1) - self.assertEqual( - mock_popen.call_args, - call([ - mock_sys.executable, '-m', 'pip', 'install', '--quiet', - TEST_NEW_REQ, '--target', os.path.abspath(target) - ], stdin=PIPE, stdout=PIPE, stderr=PIPE) - ) - - @patch('homeassistant.util.package._LOGGER') - @patch('homeassistant.util.package.sys') - def test_install_error(self, mock_sys, mock_logger, mock_exists, - mock_popen): - """Test an install with a target.""" - mock_exists.return_value = False - mock_popen.return_value = self.mock_process - self.mock_process.returncode = 1 - - self.assertFalse(package.install_package(TEST_NEW_REQ)) - - self.assertEqual(mock_logger.error.call_count, 1) +@pytest.fixture +def mock_sys(): + """Mock sys.""" + with patch('homeassistant.util.package.sys', spec=object) as sys_mock: + sys_mock.executable = 'python3' + yield sys_mock -class TestPackageUtilCheckPackageExists(unittest.TestCase): - """Test for homeassistant.util.package module.""" +@pytest.fixture +def mock_exists(): + """Mock check_package_exists.""" + with patch('homeassistant.util.package.check_package_exists') as mock: + mock.return_value = False + yield mock - def test_check_package_global(self): - """Test for a globally-installed package.""" - installed_package = list(pkg_resources.working_set)[0].project_name - self.assertTrue(package.check_package_exists(installed_package, None)) +@pytest.fixture +def deps_dir(): + """Return path to deps directory.""" + return os.path.abspath('/deps_dir') - def test_check_package_local(self): - """Test for a locally-installed package.""" - lib_dir = get_python_lib() - installed_package = list(pkg_resources.working_set)[0].project_name - self.assertTrue( - package.check_package_exists(installed_package, lib_dir) - ) +@pytest.fixture +def lib_dir(deps_dir): + """Return path to lib directory.""" + return os.path.join(deps_dir, 'lib_dir') - def test_check_package_zip(self): - """Test for an installed zip package.""" - self.assertFalse(package.check_package_exists(TEST_ZIP_REQ, None)) + +@pytest.fixture +def mock_popen(lib_dir): + """Return a Popen mock.""" + with patch('homeassistant.util.package.Popen') as popen_mock: + popen_mock.return_value.communicate.return_value = ( + bytes(lib_dir, 'utf-8'), b'error') + popen_mock.return_value.returncode = 0 + yield popen_mock + + +@pytest.fixture +def mock_env_copy(): + """Mock os.environ.copy.""" + with patch('homeassistant.util.package.os.environ.copy') as env_copy: + env_copy.return_value = {} + yield env_copy + + +@pytest.fixture +def mock_venv(): + """Mock homeassistant.util.package.running_under_virtualenv.""" + with patch('homeassistant.util.package.running_under_virtualenv') as mock: + mock.return_value = True + yield mock + + +@asyncio.coroutine +def mock_async_subprocess(): + """Return an async Popen mock.""" + async_popen = MagicMock() + + @asyncio.coroutine + def communicate(input=None): + """Communicate mock.""" + stdout = bytes('/deps_dir/lib_dir', 'utf-8') + return (stdout, None) + + async_popen.communicate = communicate + return async_popen + + +def test_install_existing_package(mock_exists, mock_popen): + """Test an install attempt on an existing package.""" + mock_exists.return_value = True + assert package.install_package(TEST_EXIST_REQ) + assert mock_exists.call_count == 1 + assert mock_exists.call_args == call(TEST_EXIST_REQ) + assert mock_popen.return_value.communicate.call_count == 0 + + +def test_install(mock_sys, mock_exists, mock_popen, mock_env_copy, mock_venv): + """Test an install attempt on a package that doesn't exist.""" + env = mock_env_copy() + assert package.install_package(TEST_NEW_REQ, False) + assert mock_exists.call_count == 1 + assert mock_popen.call_count == 1 + assert ( + mock_popen.call_args == + call([ + mock_sys.executable, '-m', 'pip', 'install', '--quiet', + TEST_NEW_REQ + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_install_upgrade( + mock_sys, mock_exists, mock_popen, mock_env_copy, mock_venv): + """Test an upgrade attempt on a package.""" + env = mock_env_copy() + assert package.install_package(TEST_NEW_REQ) + assert mock_exists.call_count == 1 + assert mock_popen.call_count == 1 + assert ( + mock_popen.call_args == + call([ + mock_sys.executable, '-m', 'pip', 'install', '--quiet', + TEST_NEW_REQ, '--upgrade' + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_install_target( + mock_sys, mock_exists, mock_popen, mock_env_copy, mock_venv): + """Test an install with a target.""" + target = 'target_folder' + env = mock_env_copy() + env['PYTHONUSERBASE'] = os.path.abspath(target) + mock_venv.return_value = False + mock_sys.platform = 'linux' + args = [ + mock_sys.executable, '-m', 'pip', 'install', '--quiet', + TEST_NEW_REQ, '--user', '--prefix='] + + assert package.install_package(TEST_NEW_REQ, False, target=target) + assert mock_exists.call_count == 1 + assert mock_popen.call_count == 1 + assert ( + mock_popen.call_args == + call(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_install_target_venv( + mock_sys, mock_exists, mock_popen, mock_env_copy, mock_venv): + """Test an install with a target in a virtual environment.""" + target = 'target_folder' + with pytest.raises(AssertionError): + package.install_package(TEST_NEW_REQ, False, target=target) + + +def test_install_error(caplog, mock_sys, mock_exists, mock_popen, mock_venv): + """Test an install with a target.""" + caplog.set_level(logging.WARNING) + mock_popen.return_value.returncode = 1 + assert not package.install_package(TEST_NEW_REQ) + assert len(caplog.records) == 1 + for record in caplog.records: + assert record.levelname == 'ERROR' + + +def test_install_constraint( + mock_sys, mock_exists, mock_popen, mock_env_copy, mock_venv): + """Test install with constraint file on not installed package.""" + env = mock_env_copy() + constraints = 'constraints_file.txt' + assert package.install_package( + TEST_NEW_REQ, False, constraints=constraints) + assert mock_exists.call_count == 1 + assert mock_popen.call_count == 1 + assert ( + mock_popen.call_args == + call([ + mock_sys.executable, '-m', 'pip', 'install', '--quiet', + TEST_NEW_REQ, '--constraint', constraints + ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + ) + assert mock_popen.return_value.communicate.call_count == 1 + + +def test_check_package_global(): + """Test for an installed package.""" + installed_package = list(pkg_resources.working_set)[0].project_name + assert package.check_package_exists(installed_package) + + +def test_check_package_zip(): + """Test for an installed zip package.""" + assert not package.check_package_exists(TEST_ZIP_REQ) + + +def test_get_user_site(deps_dir, lib_dir, mock_popen, mock_env_copy): + """Test get user site directory.""" + env = mock_env_copy() + env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) + args = [sys.executable, '-m', 'site', '--user-site'] + ret = package.get_user_site(deps_dir) + assert mock_popen.call_count == 1 + assert mock_popen.call_args == call( + args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) + assert ret == lib_dir + + +@asyncio.coroutine +def test_async_get_user_site(hass, mock_env_copy): + """Test async get user site directory.""" + deps_dir = '/deps_dir' + env = mock_env_copy() + env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) + args = [sys.executable, '-m', 'site', '--user-site'] + with patch('homeassistant.util.package.asyncio.create_subprocess_exec', + return_value=mock_async_subprocess()) as popen_mock: + ret = yield from package.async_get_user_site(deps_dir, hass.loop) + assert popen_mock.call_count == 1 + assert popen_mock.call_args == call( + *args, loop=hass.loop, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, + env=env) + assert ret == os.path.join(deps_dir, 'lib_dir') diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 0ccb6f5d6d0..a15efb7a77e 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -2,6 +2,7 @@ import io import os import unittest +import logging from unittest.mock import patch from homeassistant.exceptions import HomeAssistantError @@ -59,6 +60,13 @@ class TestYaml(unittest.TestCase): assert doc['password'] == "secret_password" del os.environ["PASSWORD"] + def test_environment_variable_default(self): + """Test config file with default value for environment variable.""" + conf = "password: !env_var PASSWORD secret_password" + with io.StringIO(conf) as file: + doc = yaml.yaml.safe_load(file) + assert doc['password'] == "secret_password" + def test_invalid_enviroment_variable(self): """Test config file with no enviroment variable sat.""" conf = "password: !env_var PASSWORD" @@ -372,6 +380,16 @@ class TestSecrets(unittest.TestCase): _yaml = load_yaml(self._yaml_path, yaml_str) self.assertEqual({'http': {'api_password': 'yeah'}}, _yaml) + @patch.object(yaml, 'credstash') + def test_secrets_credstash(self, mock_credstash): + """Test credstash fallback & get_password.""" + mock_credstash.getSecret.return_value = 'yeah' + yaml_str = 'http:\n api_password: !secret http_pw_credstash' + _yaml = load_yaml(self._yaml_path, yaml_str) + log = logging.getLogger() + log.error(_yaml['http']) + self.assertEqual({'api_password': 'yeah'}, _yaml['http']) + def test_secrets_logger_removed(self): """Ensure logger: debug was removed.""" with self.assertRaises(yaml.HomeAssistantError):