diff --git a/.coveragerc b/.coveragerc index bd99e3ac2e2..07d84523780 100644 --- a/.coveragerc +++ b/.coveragerc @@ -62,6 +62,9 @@ omit = homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py + homeassistant/components/daikin.py + homeassistant/components/*/daikin.py + homeassistant/components/deconz/* homeassistant/components/*/deconz.py @@ -82,6 +85,9 @@ omit = homeassistant/components/ecobee.py homeassistant/components/*/ecobee.py + homeassistant/components/egardia.py + homeassistant/components/*/egardia.py + homeassistant/components/enocean.py homeassistant/components/*/enocean.py @@ -181,6 +187,9 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/pilight.py + homeassistant/components/*/pilight.py + homeassistant/components/qwikswitch.py homeassistant/components/*/qwikswitch.py @@ -244,6 +253,9 @@ omit = homeassistant/components/notify/twilio_sms.py homeassistant/components/notify/twilio_call.py + homeassistant/components/upcloud.py + homeassistant/components/*/upcloud.py + homeassistant/components/usps.py homeassistant/components/*/usps.py @@ -293,13 +305,9 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py - homeassistant/components/daikin.py - homeassistant/components/*/daikin.py - homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py - homeassistant/components/alarm_control_panel/egardia.py homeassistant/components/alarm_control_panel/ialarm.py homeassistant/components/alarm_control_panel/manual_mqtt.py homeassistant/components/alarm_control_panel/nx584.py @@ -312,7 +320,6 @@ omit = homeassistant/components/binary_sensor/hikvision.py homeassistant/components/binary_sensor/iss.py homeassistant/components/binary_sensor/mystrom.py - homeassistant/components/binary_sensor/pilight.py homeassistant/components/binary_sensor/ping.py homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py @@ -353,12 +360,14 @@ omit = homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py + homeassistant/components/device_tracker/asuswrt.py homeassistant/components/device_tracker/automatic.py homeassistant/components/device_tracker/bbox.py homeassistant/components/device_tracker/bluetooth_le_tracker.py homeassistant/components/device_tracker/bluetooth_tracker.py homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/cisco_ios.py + homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py @@ -464,6 +473,7 @@ omit = homeassistant/components/media_player/russound_rio.py homeassistant/components/media_player/russound_rnet.py homeassistant/components/media_player/snapcast.py + homeassistant/components/media_player/songpal.py homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py @@ -508,6 +518,7 @@ omit = homeassistant/components/notify/simplepush.py homeassistant/components/notify/slack.py homeassistant/components/notify/smtp.py + homeassistant/components/notify/synology_chat.py homeassistant/components/notify/syslog.py homeassistant/components/notify/telegram.py homeassistant/components/notify/telstra.py @@ -619,10 +630,12 @@ omit = homeassistant/components/sensor/ripple.py homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py + homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/shodan.py + homeassistant/components/sensor/simulated.py homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py homeassistant/components/sensor/snmp.py @@ -639,7 +652,6 @@ omit = homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py homeassistant/components/sensor/ted5000.py - homeassistant/components/sensor/teksavvy.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/tibber.py homeassistant/components/sensor/time_date.py @@ -658,6 +670,7 @@ omit = homeassistant/components/sensor/worxlandroid.py homeassistant/components/sensor/xbox_live.py homeassistant/components/sensor/zamg.py + homeassistant/components/sensor/zestimate.py homeassistant/components/shiftr.py homeassistant/components/spc.py homeassistant/components/switch/acer_projector.py @@ -675,7 +688,6 @@ omit = homeassistant/components/switch/mystrom.py homeassistant/components/switch/netio.py homeassistant/components/switch/orvibo.py - homeassistant/components/switch/pilight.py homeassistant/components/switch/pulseaudio_loopback.py homeassistant/components/switch/rainbird.py homeassistant/components/switch/rainmachine.py diff --git a/.gitignore b/.gitignore index 0d55cae3c9d..b3774b06bc8 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ desktop.ini # mypy /.mypy_cache/* + +# Secrets +.lokalise_token diff --git a/.travis.yml b/.travis.yml index 3d6789ea586..fce86348817 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,12 +6,10 @@ addons: matrix: fast_finish: true include: - - python: "3.4.2" + - python: "3.5.3" env: TOXENV=lint - - python: "3.4.2" + - python: "3.5.3" env: TOXENV=pylint - - python: "3.4.2" - env: TOXENV=py34 # - python: "3.5" # env: TOXENV=typing - python: "3.5.3" @@ -30,4 +28,15 @@ cache: install: pip install -U tox coveralls language: python script: travis_wait 30 tox --develop +services: + - docker +before_deploy: + - docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 +deploy: + skip_cleanup: true + provider: script + script: script/travis_deploy + on: + branch: dev + condition: $TOXENV = lint after_success: coveralls diff --git a/CODEOWNERS b/CODEOWNERS old mode 100755 new mode 100644 index a5b5cfcb32c..fedab8f6ae4 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio # Individual components homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt +homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 @@ -54,6 +55,7 @@ homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/history_graph.py @andrey-git homeassistant/components/light/tplink.py @rytilahti homeassistant/components/light/yeelight.py @rytilahti +homeassistant/components/media_player/emby.py @mezz64 homeassistant/components/media_player/kodi.py @armills homeassistant/components/media_player/mediaroom.py @dgomes homeassistant/components/media_player/monoprice.py @etsinko @@ -77,6 +79,8 @@ homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi homeassistant/components/*/axis.py @kane610 homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen +homeassistant/components/eight_sleep.py @mezz64 +homeassistant/components/*/eight_sleep.py @mezz64 homeassistant/components/hive.py @Rendili @KJonline homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 1cf6ecf7b98..319d00e6de5 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -15,7 +15,6 @@ from homeassistant.const import ( __version__, EVENT_HOMEASSISTANT_START, REQUIRED_PYTHON_VER, - REQUIRED_PYTHON_VER_WIN, RESTART_EXIT_CODE, ) @@ -33,12 +32,7 @@ def attempt_use_uvloop(): def validate_python() -> None: """Validate that the right Python version is running.""" - if sys.platform == "win32" and \ - sys.version_info[:3] < REQUIRED_PYTHON_VER_WIN: - print("Home Assistant requires at least Python {}.{}.{}".format( - *REQUIRED_PYTHON_VER_WIN)) - sys.exit(1) - elif sys.version_info[:3] < REQUIRED_PYTHON_VER: + if sys.version_info[:3] < REQUIRED_PYTHON_VER: print("Home Assistant requires at least Python {}.{}.{}".format( *REQUIRED_PYTHON_VER)) sys.exit(1) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 4971cbccc9c..2f093f061d9 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -112,18 +112,13 @@ def async_from_config_dict(config: Dict[str, Any], if not loader.PREPARED: yield from hass.async_add_job(loader.prepare, hass) + # Make a copy because we are mutating it. + config = OrderedDict(config) + # Merge packages conf_util.merge_packages_config( config, core_config.get(conf_util.CONF_PACKAGES, {})) - # Make a copy because we are mutating it. - # Use OrderedDict in case original one was one. - # Convert values to dictionaries if they are None - new_config = OrderedDict() - for key, value in config.items(): - new_config[key] = value or {} - config = new_config - hass.config_entries = config_entries.ConfigEntries(hass, config) yield from hass.config_entries.async_load() diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 6b306adad5b..f0c4f7bb3e2 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -156,9 +156,10 @@ def async_setup(hass, config): hass.services.async_register( ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}")) + intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( - intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}")) + intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, + "Turned {} off")) hass.helpers.intent.async_register(intent.ServiceIntentHandler( intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}")) diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index ceb815f11a0..0e96e6448ff 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyalarmdotcom==0.3.0'] +REQUIREMENTS = ['pyalarmdotcom==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index e6a840910b8..d48a107f33d 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -26,7 +26,7 @@ DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'CONCORD232' DEFAULT_PORT = 5007 -SCAN_INTERVAL = timedelta(seconds=1) +SCAN_INTERVAL = timedelta(seconds=10) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, diff --git a/homeassistant/components/alarm_control_panel/egardia.py b/homeassistant/components/alarm_control_panel/egardia.py index e9f08cd4fed..64e165f6b16 100644 --- a/homeassistant/components/alarm_control_panel/egardia.py +++ b/homeassistant/components/alarm_control_panel/egardia.py @@ -4,130 +4,65 @@ Interfaces with Egardia/Woonveilig alarm control panel. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.egardia/ """ +import asyncio import logging import requests -import voluptuous as vol import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) -import homeassistant.exceptions as exc -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pythonegardia==1.0.26'] + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED) +from homeassistant.components.egardia import ( + EGARDIA_DEVICE, EGARDIA_SERVER, + REPORT_SERVER_CODES_IGNORE, CONF_REPORT_SERVER_CODES, + CONF_REPORT_SERVER_ENABLED, CONF_REPORT_SERVER_PORT + ) +REQUIREMENTS = ['pythonegardia==1.0.38'] _LOGGER = logging.getLogger(__name__) -CONF_REPORT_SERVER_CODES = 'report_server_codes' -CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' -CONF_REPORT_SERVER_PORT = 'report_server_port' -CONF_REPORT_SERVER_CODES_IGNORE = 'ignore' -CONF_VERSION = 'version' - -DEFAULT_NAME = 'Egardia' -DEFAULT_PORT = 80 -DEFAULT_REPORT_SERVER_ENABLED = False -DEFAULT_REPORT_SERVER_PORT = 52010 -DEFAULT_VERSION = 'GATE-01' -DOMAIN = 'egardia' -D_EGARDIASRV = 'egardiaserver' - -NOTIFICATION_ID = 'egardia_notification' -NOTIFICATION_TITLE = 'Egardia' - STATES = { 'ARM': STATE_ALARM_ARMED_AWAY, 'DAY HOME': STATE_ALARM_ARMED_HOME, 'DISARM': STATE_ALARM_DISARMED, - 'HOME': STATE_ALARM_ARMED_HOME, - 'TRIGGERED': STATE_ALARM_TRIGGERED, - 'UNKNOWN': STATE_UNKNOWN, + 'ARMHOME': STATE_ALARM_ARMED_HOME, + 'TRIGGERED': STATE_ALARM_TRIGGERED } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_REPORT_SERVER_CODES): vol.All(cv.ensure_list), - vol.Optional(CONF_REPORT_SERVER_ENABLED, - default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean, - vol.Optional(CONF_REPORT_SERVER_PORT, default=DEFAULT_REPORT_SERVER_PORT): - cv.port, -}) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Egardia platform.""" - from pythonegardia import egardiadevice - from pythonegardia import egardiaserver - - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - rs_enabled = config.get(CONF_REPORT_SERVER_ENABLED) - rs_port = config.get(CONF_REPORT_SERVER_PORT) - rs_codes = config.get(CONF_REPORT_SERVER_CODES) - version = config.get(CONF_VERSION) - - try: - egardiasystem = egardiadevice.EgardiaDevice( - host, port, username, password, '', version) - except requests.exceptions.RequestException: - raise exc.PlatformNotReady() - except egardiadevice.UnauthorizedError: - _LOGGER.error("Unable to authorize. Wrong password or username") - return - - eg_dev = EgardiaAlarm( - name, egardiasystem, rs_enabled, rs_codes) - - if rs_enabled: - # Set up the egardia server - _LOGGER.info("Setting up EgardiaServer") - try: - if D_EGARDIASRV not in hass.data: - server = egardiaserver.EgardiaServer('', rs_port) - bound = server.bind() - if not bound: - raise IOError( - "Binding error occurred while starting EgardiaServer") - hass.data[D_EGARDIASRV] = server - server.start() - except IOError: - return - hass.data[D_EGARDIASRV].register_callback(eg_dev.handle_status_event) - - def handle_stop_event(event): - """Call function for Home Assistant stop event.""" - hass.data[D_EGARDIASRV].stop() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) - - add_devices([eg_dev], True) + device = EgardiaAlarm( + discovery_info['name'], + hass.data[EGARDIA_DEVICE], + discovery_info[CONF_REPORT_SERVER_ENABLED], + discovery_info.get(CONF_REPORT_SERVER_CODES), + discovery_info[CONF_REPORT_SERVER_PORT]) + # add egardia alarm device + add_devices([device], True) class EgardiaAlarm(alarm.AlarmControlPanel): """Representation of a Egardia alarm.""" - def __init__(self, name, egardiasystem, rs_enabled=False, rs_codes=None): + def __init__(self, name, egardiasystem, + rs_enabled=False, rs_codes=None, rs_port=52010): """Initialize the Egardia alarm.""" self._name = name self._egardiasystem = egardiasystem self._status = None self._rs_enabled = rs_enabled - if rs_codes is not None: - self._rs_codes = rs_codes[0] - else: - self._rs_codes = rs_codes + self._rs_codes = rs_codes + self._rs_port = rs_port + + @asyncio.coroutine + def async_added_to_hass(self): + """Add Egardiaserver callback if enabled.""" + if self._rs_enabled: + _LOGGER.debug("Registering callback to Egardiaserver") + self.hass.data[EGARDIA_SERVER].register_callback( + self.handle_status_event) @property def name(self): @@ -156,31 +91,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel): def lookupstatusfromcode(self, statuscode): """Look at the rs_codes and returns the status from the code.""" - status = 'UNKNOWN' - if self._rs_codes is not None: - statuscode = str(statuscode).strip() - for i in self._rs_codes: - val = str(self._rs_codes[i]).strip() - if ',' in val: - splitted = val.split(',') - for code in splitted: - code = str(code).strip() - if statuscode == code: - status = i.upper() - break - elif statuscode == val: - status = i.upper() - break + status = next(( + status_group.upper() for status_group, codes + in self._rs_codes.items() for code in codes + if statuscode == code), 'UNKNOWN') return status def parsestatus(self, status): """Parse the status.""" _LOGGER.debug("Parsing status %s", status) # Ignore the statuscode if it is IGNORE - if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE: - _LOGGER.debug("Not ignoring status") - newstatus = ([v for k, v in STATES.items() - if status.upper() == k][0]) + if status.lower().strip() != REPORT_SERVER_CODES_IGNORE: + _LOGGER.debug("Not ignoring status %s", status) + newstatus = STATES.get(status.upper()) + _LOGGER.debug("newstatus %s", newstatus) self._status = newstatus else: _LOGGER.error("Ignoring status") diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index f25b0cc130c..d272ebcb1c0 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -131,8 +131,7 @@ class APIEventStream(HomeAssistantView): msg = "data: {}\n\n".format(payload) _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), msg.strip()) - response.write(msg.encode("UTF-8")) - yield from response.drain() + yield from response.write(msg.encode("UTF-8")) except asyncio.TimeoutError: yield from to_write.put(STREAM_PING_PAYLOAD) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index c12e18ef09c..2a7da86c6cf 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.3.0'] +REQUIREMENTS = ['py-august==0.4.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 @@ -159,7 +159,7 @@ class AugustData: self._api = api self._access_token = access_token self._doorbells = self._api.get_doorbells(self._access_token) or [] - self._locks = self._api.get_locks(self._access_token) or [] + self._locks = self._api.get_operable_locks(self._access_token) or [] self._house_ids = [d.house_id for d in self._doorbells + self._locks] self._doorbell_detail_by_id = {} diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index df271a7ebac..ad475be76ca 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -4,7 +4,7 @@ Component to interface with binary sensors. For more details about this component, please refer to the documentation at https://home-assistant.io/components/binary_sensor/ """ -import asyncio + from datetime import timedelta import logging @@ -28,6 +28,7 @@ DEVICE_CLASSES = [ 'gas', # On means gas detected, Off means no gas (clear) 'heat', # On means hot, Off means normal 'light', # On means light detected, Off means no light + 'lock', # On means open (unlocked), Off means closed (locked) 'moisture', # On means wet, Off means dry 'motion', # On means motion detected, Off means no motion (clear) 'moving', # On means moving, Off means not moving (stopped) @@ -47,13 +48,12 @@ DEVICE_CLASSES = [ DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for binary sensors.""" component = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) return True diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py index 3bac561700a..f2acef47e82 100644 --- a/homeassistant/components/binary_sensor/concord232.py +++ b/homeassistant/components/binary_sensor/concord232.py @@ -27,7 +27,7 @@ DEFAULT_NAME = 'Alarm' DEFAULT_PORT = '5007' DEFAULT_SSL = False -SCAN_INTERVAL = datetime.timedelta(seconds=1) +SCAN_INTERVAL = datetime.timedelta(seconds=10) ZONE_TYPES_SCHEMA = vol.Schema({ cv.positive_int: vol.In(DEVICE_CLASSES), diff --git a/homeassistant/components/binary_sensor/egardia.py b/homeassistant/components/binary_sensor/egardia.py new file mode 100644 index 00000000000..ab88de9d3c9 --- /dev/null +++ b/homeassistant/components/binary_sensor/egardia.py @@ -0,0 +1,78 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.egardia/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import STATE_ON, STATE_OFF +from homeassistant.components.egardia import ( + EGARDIA_DEVICE, ATTR_DISCOVER_DEVICES) +_LOGGER = logging.getLogger(__name__) + +EGARDIA_TYPE_TO_DEVICE_CLASS = {'IR Sensor': 'motion', + 'Door Contact': 'opening', + 'IR': 'motion'} + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Initialize the platform.""" + if (discovery_info is None or + discovery_info[ATTR_DISCOVER_DEVICES] is None): + return + + disc_info = discovery_info[ATTR_DISCOVER_DEVICES] + # multiple devices here! + async_add_devices( + ( + EgardiaBinarySensor( + sensor_id=disc_info[sensor]['id'], + name=disc_info[sensor]['name'], + egardia_system=hass.data[EGARDIA_DEVICE], + device_class=EGARDIA_TYPE_TO_DEVICE_CLASS.get( + disc_info[sensor]['type'], None) + ) + for sensor in disc_info + ), True) + + +class EgardiaBinarySensor(BinarySensorDevice): + """Represents a sensor based on an Egardia sensor (IR, Door Contact).""" + + def __init__(self, sensor_id, name, egardia_system, device_class): + """Initialize the sensor device.""" + self._id = sensor_id + self._name = name + self._state = None + self._device_class = device_class + self._egardia_system = egardia_system + + def update(self): + """Update the status.""" + egardia_input = self._egardia_system.getsensorstate(self._id) + self._state = STATE_ON if egardia_input else STATE_OFF + + @property + def name(self): + """The name of the device.""" + return self._name + + @property + def is_on(self): + """Whether the device is switched on.""" + return self._state == STATE_ON + + @property + def hidden(self): + """Whether the device is hidden by default.""" + # these type of sensors are probably mainly used for automations + return True + + @property + def device_class(self): + """The device class.""" + return self._device_class diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index 36ec8b7b61a..f9ff4ac0a7a 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START, ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE) -REQUIREMENTS = ['pyhik==0.1.4'] +REQUIREMENTS = ['pyhik==0.1.8'] _LOGGER = logging.getLogger(__name__) CONF_IGNORED = 'ignored' @@ -48,6 +48,9 @@ DEVICE_CLASS_MAP = { 'Face Detection': 'motion', 'Scene Change Detection': 'motion', 'I/O': None, + 'Unattended Baggage': 'motion', + 'Attended Baggage': 'motion', + 'Recording Failure': None, } CUSTOMIZE_SCHEMA = vol.Schema({ @@ -211,7 +214,7 @@ class HikvisionBinarySensor(BinarySensorDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._id @property diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 1874be6ec41..09c4b5c8ea7 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -2,86 +2,56 @@ Support for INSTEON dimmers via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/binary_sensor.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.loader import get_component +from homeassistant.components.insteon_plm import InsteonPLMEntity DEPENDENCIES = ['insteon_plm'] _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = {'openClosedSensor': 'opening', + 'motionSensor': 'motion', + 'doorSensor': 'door', + 'leakSensor': 'moisture'} + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the INSTEON PLM device class for the hass platform.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info('Registered %s with binary_sensor platform.', name) + _LOGGER.debug('Adding device %s entity %s to Binary Sensor platform', + device.address.hex, device.states[state_key].name) - device_list.append( - InsteonPLMBinarySensorDevice(hass, plm, address, name) - ) + new_entity = InsteonPLMBinarySensor(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMBinarySensorDevice(BinarySensorDevice): - """A Class for an Insteon device.""" +class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice): + """A Class for an Insteon device entity.""" - def __init__(self, hass, plm, address, name): - """Initialize the binarysensor.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - - self._plm.add_update_callback( - self.async_binarysensor_update, {'address': self._address}) + def __init__(self, device, state_key): + """Initialize the INSTEON PLM binary sensor.""" + super().__init__(device, state_key) + self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name + def device_class(self): + """Return the class of this sensor.""" + return self._sensor_type @property def is_on(self): """Return the boolean response if the node is on.""" - sensorstate = self._plm.get_device_attr(self._address, 'sensorstate') - _LOGGER.info("Sensor state for %s is %s", self._address, sensorstate) + sensorstate = self._insteon_device_state.value return bool(sensorstate) - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_binarysensor_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update callback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index 4dddb9bdbef..fb86244acf3 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -56,24 +56,17 @@ def setup_platform(hass, config: ConfigType, else: device_type = _detect_device_type(node) subnode_id = int(node.nid[-1]) - if device_type == 'opening': - # Door/window sensors use an optional "negative" node - if subnode_id == 4: + if (device_type == 'opening' or device_type == 'moisture'): + # These sensors use an optional "negative" subnode 2 to snag + # all state changes + if subnode_id == 2: + parent_device.add_negative_node(node) + elif subnode_id == 4: # Subnode 4 is the heartbeat node, which we will represent # as a separate binary_sensor device = ISYBinarySensorHeartbeat(node, parent_device) parent_device.add_heartbeat_device(device) devices.append(device) - elif subnode_id == 2: - parent_device.add_negative_node(node) - elif device_type == 'moisture': - # Moisture nodes have a subnode 2, but we ignore it because - # it's just the inverse of the primary node. - if subnode_id == 4: - # Heartbeat node - device = ISYBinarySensorHeartbeat(node, parent_device) - parent_device.add_heartbeat_device(device) - devices.append(device) else: # We don't yet have any special logic for other sensor types, # so add the nodes as individual devices diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 2b33d6850d6..834186b8b18 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP binary sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.knx/ """ -import asyncio import voluptuous as vol @@ -26,6 +25,7 @@ CONF_DEFAULT_HOOK = 'on' CONF_COUNTER = 'counter' CONF_DEFAULT_COUNTER = 1 CONF_ACTION = 'action' +CONF_RESET_AFTER = 'reset_after' CONF__ACTION = 'turn_off_action' @@ -49,12 +49,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE_CLASS): cv.string, vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT): cv.positive_int, + vol.Optional(CONF_RESET_AFTER): cv.positive_int, vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA, }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up binary sensor(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -82,7 +83,8 @@ def async_add_devices_config(hass, config, async_add_devices): name=name, group_address=config.get(CONF_ADDRESS), device_class=config.get(CONF_DEVICE_CLASS), - significant_bit=config.get(CONF_SIGNIFICANT_BIT)) + significant_bit=config.get(CONF_SIGNIFICANT_BIT), + reset_after=config.get(CONF_RESET_AFTER)) hass.data[DATA_KNX].xknx.devices.add(binary_sensor) entity = KNXBinarySensor(hass, binary_sensor) @@ -111,11 +113,10 @@ class KNXBinarySensor(BinarySensorDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property diff --git a/homeassistant/components/binary_sensor/upcloud.py b/homeassistant/components/binary_sensor/upcloud.py new file mode 100644 index 00000000000..868a2e8ddd0 --- /dev/null +++ b/homeassistant/components/binary_sensor/upcloud.py @@ -0,0 +1,38 @@ +""" +Support for monitoring the state of UpCloud servers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.upcloud/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.upcloud import ( + UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['upcloud'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the UpCloud server binary sensor.""" + upcloud = hass.data[DATA_UPCLOUD] + + servers = config.get(CONF_SERVERS) + + devices = [UpCloudBinarySensor(upcloud, uuid) for uuid in servers] + + add_devices(devices, True) + + +class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice): + """Representation of an UpCloud server sensor.""" diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 99037f60107..2ed0de66b18 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -319,7 +319,10 @@ class XiaomiButton(XiaomiBinarySensor): click_type = 'double' elif value == 'both_click': click_type = 'both' + elif value == 'shake': + click_type = 'shake' else: + _LOGGER.warning("Unsupported click_type detected: %s", value) return False self._hass.bus.fire('click', { diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index de7896e595b..bf038a62465 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -4,7 +4,6 @@ Binary sensors on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.zha/ """ -import asyncio import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice @@ -25,8 +24,8 @@ CLASS_MAPPING = { } -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Zigbee Home Automation binary sensors.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: @@ -39,19 +38,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device_class = None cluster = in_clusters[IasZone.cluster_id] if discovery_info['new_join']: - yield from cluster.bind() + await cluster.bind() ieee = cluster.endpoint.device.application.ieee - yield from cluster.write_attributes({'cie_addr': ieee}) + await cluster.write_attributes({'cie_addr': ieee}) try: - zone_type = yield from cluster['zone_type'] + zone_type = await cluster['zone_type'] device_class = CLASS_MAPPING.get(zone_type, None) except Exception: # pylint: disable=broad-except # If we fail to read from the device, use a non-specific class pass sensor = BinarySensor(device_class, **discovery_info) - async_add_devices([sensor]) + async_add_devices([sensor], update_before_add=True) class BinarySensor(zha.Entity, BinarySensorDevice): @@ -66,6 +65,11 @@ class BinarySensor(zha.Entity, BinarySensorDevice): from zigpy.zcl.clusters.security import IasZone self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id] + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False + @property def is_on(self) -> bool: """Return True if entity is on.""" @@ -83,7 +87,18 @@ class BinarySensor(zha.Entity, BinarySensorDevice): if command_id == 0: self._state = args[0] & 3 _LOGGER.debug("Updated alarm state: %s", self._state) - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() elif command_id == 1: _LOGGER.debug("Enroll requested") - self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0)) + res = self._ias_zone_cluster.enroll_response(0, 0) + self.hass.async_add_job(res) + + async def async_update(self): + """Retrieve latest state.""" + from bellows.types.basic import uint16_t + + result = await zha.safe_read(self._endpoint.ias_zone, + ['zone_status']) + state = result.get('zone_status', self._state) + if isinstance(state, (int, uint16_t)): + self._state = result.get('zone_status', self._state) & 3 diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index a531d25841b..5321ec3d860 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -264,9 +264,9 @@ class Camera(Entity): 'boundary=--frameboundary') yield from response.prepare(request) - def write(img_bytes): + async def write(img_bytes): """Write image to stream.""" - response.write(bytes( + await response.write(bytes( '--frameboundary\r\n' 'Content-Type: {}\r\n' 'Content-Length: {}\r\n\r\n'.format( @@ -282,15 +282,14 @@ class Camera(Entity): break if img_bytes and img_bytes != last_image: - write(img_bytes) + yield from write(img_bytes) # Chrome seems to always ignore first picture, # print it twice. if last_image is None: - write(img_bytes) + yield from write(img_bytes) last_image = img_bytes - yield from response.drain() yield from asyncio.sleep(.5) diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 1340c52459d..d48f06539f4 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -33,6 +33,9 @@ DEFAULT_PORT = 5000 DEFAULT_USERNAME = 'admin' DEFAULT_PASSWORD = '888888' DEFAULT_ARGUMENTS = '-q:v 2' +DEFAULT_PROFILE = 0 + +CONF_PROFILE = "profile" ATTR_PAN = "pan" ATTR_TILT = "tilt" @@ -57,6 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): + vol.All(vol.Coerce(int), vol.Range(min=0)), }) SERVICE_PTZ_SCHEMA = vol.Schema({ @@ -67,8 +72,7 @@ SERVICE_PTZ_SCHEMA = vol.Schema({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a ONVIF camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)): return @@ -91,7 +95,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz, schema=SERVICE_PTZ_SCHEMA) - async_add_devices([ONVIFHassCamera(hass, config)]) + add_devices([ONVIFHassCamera(hass, config)]) class ONVIFHassCamera(Camera): @@ -114,10 +118,17 @@ class ONVIFHassCamera(Camera): config.get(CONF_USERNAME), config.get(CONF_PASSWORD) ) media_service = camera.create_media_service() - stream_uri = media_service.GetStreamUri( - {'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}} - ) - self._input = stream_uri.Uri.replace( + self._profiles = media_service.GetProfiles() + self._profile_index = config.get(CONF_PROFILE) + if self._profile_index >= len(self._profiles): + _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d." + " Using the last profile.", + self._name, self._profile_index) + self._profile_index = -1 + req = media_service.create_type('GetStreamUri') + # pylint: disable=protected-access + req.ProfileToken = self._profiles[self._profile_index]._token + self._input = media_service.GetStreamUri(req).Uri.replace( 'rtsp://', 'rtsp://{}:{}@'.format( config.get(CONF_USERNAME), config.get(CONF_PASSWORD)), 1) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py new file mode 100644 index 00000000000..56b9db5c0ec --- /dev/null +++ b/homeassistant/components/camera/proxy.py @@ -0,0 +1,262 @@ +""" +Proxy camera platform that enables image processing of camera data. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/proxy +""" +import logging +import asyncio +import aiohttp +import async_timeout + +import voluptuous as vol + +from homeassistant.util.async import run_coroutine_threadsafe +from homeassistant.helpers import config_validation as cv + +import homeassistant.util.dt as dt_util +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH) +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.helpers.aiohttp_client import ( + async_get_clientsession, async_aiohttp_proxy_web) + +REQUIREMENTS = ['pillow==5.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MAX_IMAGE_WIDTH = "max_image_width" +CONF_IMAGE_QUALITY = "image_quality" +CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" +CONF_FORCE_RESIZE = "force_resize" +CONF_MAX_STREAM_WIDTH = "max_stream_width" +CONF_STREAM_QUALITY = "stream_quality" +CONF_CACHE_IMAGES = "cache_images" + +DEFAULT_BASENAME = "Camera Proxy" +DEFAULT_QUALITY = 75 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_IMAGE_QUALITY): int, + vol.Optional(CONF_IMAGE_REFRESH_RATE): float, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_STREAM_QUALITY): int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Proxy camera platform.""" + async_add_devices([ProxyCamera(hass, config)]) + + +async def _read_frame(req): + """Read a single frame from an MJPEG stream.""" + # based on https://gist.github.com/russss/1143799 + import cgi + # Read in HTTP headers: + stream = req.content + # multipart/x-mixed-replace; boundary=--frameboundary + _mimetype, options = cgi.parse_header(req.headers['content-type']) + boundary = options.get('boundary').encode('utf-8') + if not boundary: + _LOGGER.error("Malformed MJPEG missing boundary") + raise Exception("Can't find content-type") + + line = await stream.readline() + # Seek ahead to the first chunk + while line.strip() != boundary: + line = await stream.readline() + # Read in chunk headers + while line.strip() != b'': + parts = line.split(b':') + if len(parts) > 1 and parts[0].lower() == b'content-length': + # Grab chunk length + length = int(parts[1].strip()) + line = await stream.readline() + image = await stream.read(length) + return image + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + if not opts: + return image + + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width + + img = Image.open(io.BytesIO(image)) + imgfmt = str(img.format) + if imgfmt != 'PNG' and imgfmt != 'JPEG': + _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + return image + + (old_width, old_height) = img.size + old_size = len(image) + if old_width <= new_width: + if opts.quality is None: + _LOGGER.debug("Image is smaller-than / equal-to requested width") + return image + new_width = old_width + + scale = new_width / float(old_width) + new_height = int((float(old_height)*float(scale))) + + img = img.resize((new_width, new_height), Image.ANTIALIAS) + imgbuf = io.BytesIO() + img.save(imgbuf, "JPEG", optimize=True, quality=quality) + newimage = imgbuf.getvalue() + if not opts.force_resize and len(newimage) >= old_size: + _LOGGER.debug("Using original image(%d bytes) " + "because resized image (%d bytes) is not smaller", + old_size, len(newimage)) + return image + + _LOGGER.debug("Resized image " + "from (%dx%d - %d bytes) " + "to (%dx%d - %d bytes)", + old_width, old_height, old_size, + new_width, new_height, len(newimage)) + return newimage + + +class ImageOpts(): + """The representation of image options.""" + + def __init__(self, max_width, quality, force_resize): + """Initialize image options.""" + self.max_width = max_width + self.quality = quality + self.force_resize = force_resize + + def __bool__(self): + """Bool evalution rules.""" + return bool(self.max_width or self.quality) + + +class ProxyCamera(Camera): + """The representation of a Proxy camera.""" + + def __init__(self, hass, config): + """Initialize a proxy camera component.""" + super().__init__() + self.hass = hass + self._proxied_camera = config.get(CONF_ENTITY_ID) + self._name = ( + config.get(CONF_NAME) or + "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) + self._image_opts = ImageOpts( + config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_IMAGE_QUALITY), + config.get(CONF_FORCE_RESIZE)) + + self._stream_opts = ImageOpts( + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_STREAM_QUALITY), + True) + + self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) + self._cache_images = bool( + config.get(CONF_IMAGE_REFRESH_RATE) + or config.get(CONF_CACHE_IMAGES)) + self._last_image_time = 0 + self._last_image = None + self._headers = ( + {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} + if self.hass.config.api.api_password is not None + else None) + + def camera_image(self): + """Return camera image.""" + return run_coroutine_threadsafe( + self.async_camera_image(), self.hass.loop).result() + + async def async_camera_image(self): + """Return a still image response from the camera.""" + now = dt_util.utcnow() + + if (self._image_refresh_rate and + now < self._last_image_time + self._image_refresh_rate): + return self._last_image + + self._last_image_time = now + url = "{}/api/camera_proxy/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + try: + websession = async_get_clientsession(self.hass) + with async_timeout.timeout(10, loop=self.hass.loop): + response = await websession.get(url, headers=self._headers) + image = await response.read() + except asyncio.TimeoutError: + _LOGGER.error("Timeout getting camera image") + return self._last_image + except aiohttp.ClientError as err: + _LOGGER.error("Error getting new camera image: %s", err) + return self._last_image + + image = await self.hass.async_add_job( + _resize_image, image, self._image_opts) + + if self._cache_images: + self._last_image = image + return image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from camera images.""" + websession = async_get_clientsession(self.hass) + url = "{}/api/camera_proxy_stream/{}".format( + self.hass.config.api.base_url, self._proxied_camera) + stream_coro = websession.get(url, headers=self._headers) + + if not self._stream_opts: + await async_aiohttp_proxy_web(self.hass, request, stream_coro) + return + + response = aiohttp.web.StreamResponse() + response.content_type = ('multipart/x-mixed-replace; ' + 'boundary=--frameboundary') + await response.prepare(request) + + def write(img_bytes): + """Write image to stream.""" + response.write(bytes( + '--frameboundary\r\n' + 'Content-Type: {}\r\n' + 'Content-Length: {}\r\n\r\n'.format( + self.content_type, len(img_bytes)), + 'utf-8') + img_bytes + b'\r\n') + + with async_timeout.timeout(10, loop=self.hass.loop): + req = await stream_coro + + try: + while True: + image = await _read_frame(req) + if not image: + break + image = await self.hass.async_add_job( + _resize_image, image, self._stream_opts) + write(image) + except asyncio.CancelledError: + _LOGGER.debug("Stream closed by frontend.") + req.close() + response = None + + finally: + if response is not None: + await response.write_eof() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py index f1f110d7c6a..91edf7d1053 100644 --- a/homeassistant/components/camera/rpi_camera.py +++ b/homeassistant/components/camera/rpi_camera.py @@ -106,6 +106,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("'%s' is not a whitelisted directory", file_path) return False + add_devices([RaspberryCamera(setup_config)]) + class RaspberryCamera(Camera): """Representation of a Raspberry Pi camera.""" diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index 8e41429baea..41fe816c479 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -38,8 +38,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up a Yi Camera.""" _LOGGER.debug('Received configuration: %s', config) async_add_devices([YiCamera(hass, config)], True) @@ -107,31 +109,29 @@ class YiCamera(Camera): self.user, self.passwd, self.host, self.port, self.path, latest_dir, videos[-1]) - @asyncio.coroutine - def async_camera_image(self): + async def async_camera_image(self): """Return a still image response from the camera.""" from haffmpeg import ImageFrame, IMAGE_JPEG - url = yield from self.hass.async_add_job(self.get_latest_video_url) + url = await self.hass.async_add_job(self.get_latest_video_url) if url != self._last_url: ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - self._last_image = yield from asyncio.shield(ffmpeg.get_image( + self._last_image = await asyncio.shield(ffmpeg.get_image( url, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments), loop=self.hass.loop) self._last_url = url return self._last_image - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) - yield from stream.open_camera( + await stream.open_camera( self._last_url, extra_cmd=self._extra_arguments) - yield from async_aiohttp_proxy_stream( + await async_aiohttp_proxy_stream( self.hass, request, stream, 'multipart/x-mixed-replace;boundary=ffserver') - yield from stream.close() + await stream.close() diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py index dfef4976eb8..03825bf48a9 100644 --- a/homeassistant/components/canary.py +++ b/homeassistant/components/canary.py @@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ['py-canary==0.4.0'] +REQUIREMENTS = ['py-canary==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index e1a5f71af83..7ea23f4fd65 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -237,14 +237,12 @@ def set_swing_mode(hass, swing_mode, entity_id=None): hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up climate devices.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_away_mode_set_service(service): + async def async_away_mode_set_service(service): """Set away mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -253,23 +251,22 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: if away_mode: - yield from climate.async_turn_away_mode_on() + await climate.async_turn_away_mode_on() else: - yield from climate.async_turn_away_mode_off() + await climate.async_turn_away_mode_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service, schema=SET_AWAY_MODE_SCHEMA) - @asyncio.coroutine - def async_hold_mode_set_service(service): + async def async_hold_mode_set_service(service): """Set hold mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -277,21 +274,20 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_hold_mode(hold_mode) + await climate.async_set_hold_mode(hold_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service, schema=SET_HOLD_MODE_SCHEMA) - @asyncio.coroutine - def async_aux_heat_set_service(service): + async def async_aux_heat_set_service(service): """Set auxiliary heater on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -300,23 +296,22 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: if aux_heat: - yield from climate.async_turn_aux_heat_on() + await climate.async_turn_aux_heat_on() else: - yield from climate.async_turn_aux_heat_off() + await climate.async_turn_aux_heat_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service, schema=SET_AUX_HEAT_SCHEMA) - @asyncio.coroutine - def async_temperature_set_service(service): + async def async_temperature_set_service(service): """Set temperature on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -333,21 +328,20 @@ def async_setup(hass, config): else: kwargs[value] = temp - yield from climate.async_set_temperature(**kwargs) + await climate.async_set_temperature(**kwargs) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service, schema=SET_TEMPERATURE_SCHEMA) - @asyncio.coroutine - def async_humidity_set_service(service): + async def async_humidity_set_service(service): """Set humidity on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -355,20 +349,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_humidity(humidity) + await climate.async_set_humidity(humidity) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service, schema=SET_HUMIDITY_SCHEMA) - @asyncio.coroutine - def async_fan_mode_set_service(service): + async def async_fan_mode_set_service(service): """Set fan mode on target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -376,20 +369,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_fan_mode(fan) + await climate.async_set_fan_mode(fan) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service, schema=SET_FAN_MODE_SCHEMA) - @asyncio.coroutine - def async_operation_set_service(service): + async def async_operation_set_service(service): """Set operating mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -397,20 +389,19 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_operation_mode(operation_mode) + await climate.async_set_operation_mode(operation_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service, schema=SET_OPERATION_MODE_SCHEMA) - @asyncio.coroutine - def async_swing_set_service(service): + async def async_swing_set_service(service): """Set swing mode on the target climate devices.""" target_climate = component.async_extract_from_service(service) @@ -418,36 +409,35 @@ def async_setup(hass, config): update_tasks = [] for climate in target_climate: - yield from climate.async_set_swing_mode(swing_mode) + await climate.async_set_swing_mode(swing_mode) if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service, schema=SET_SWING_MODE_SCHEMA) - @asyncio.coroutine - def async_on_off_service(service): + async def async_on_off_service(service): """Handle on/off calls.""" target_climate = component.async_extract_from_service(service) update_tasks = [] for climate in target_climate: if service.service == SERVICE_TURN_ON: - yield from climate.async_turn_on() + await climate.async_turn_on() elif service.service == SERVICE_TURN_OFF: - yield from climate.async_turn_off() + await climate.async_turn_off() if not climate.should_poll: continue update_tasks.append(climate.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_on_off_service, diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 1bbc5b789fb..5ce6cc2fa7a 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP climate devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.knx/ """ -import asyncio import voluptuous as vol @@ -61,8 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up climate(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -135,11 +134,10 @@ class KNXClimate(ClimateDevice): def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -187,14 +185,13 @@ class KNXClimate(ClimateDevice): """Return the maximum temperature.""" return self.device.target_temperature_max - @asyncio.coroutine - def async_set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) if temperature is None: return - yield from self.device.set_target_temperature(temperature) - yield from self.async_update_ha_state() + await self.device.set_target_temperature(temperature) + await self.async_update_ha_state() @property def current_operation(self): @@ -210,10 +207,9 @@ class KNXClimate(ClimateDevice): operation_mode in self.device.get_supported_operation_modes()] - @asyncio.coroutine - def async_set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set operation mode.""" if self.device.supports_operation_mode: from xknx.knx import HVACOperationMode knx_operation_mode = HVACOperationMode(operation_mode) - yield from self.device.set_operation_mode(knx_operation_mode) + await self.device.set_operation_mode(knx_operation_mode) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 0427514a7b5..e5c21158acb 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -29,10 +29,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ NEST_MODE_HEAT_COOL = 'heat-cool' -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_OPERATION_MODE | - SUPPORT_AWAY_MODE | SUPPORT_FAN_MODE) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Nest thermostat.""" @@ -58,6 +54,10 @@ class NestThermostat(ClimateDevice): self.device = device self._fan_list = [STATE_ON, STATE_AUTO] + # Set the default supported features + self._support_flags = (SUPPORT_TARGET_TEMPERATURE | + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE) + # Not all nest devices support cooling and heating remove unused self._operation_list = [STATE_OFF] @@ -70,11 +70,16 @@ class NestThermostat(ClimateDevice): if self.device.can_heat and self.device.can_cool: self._operation_list.append(STATE_AUTO) + self._support_flags = (self._support_flags | + SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) self._operation_list.append(STATE_ECO) # feature of device self._has_fan = self.device.has_fan + if self._has_fan: + self._support_flags = (self._support_flags | SUPPORT_FAN_MODE) # data attributes self._away = None @@ -95,7 +100,7 @@ class NestThermostat(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def unique_id(self): @@ -162,6 +167,7 @@ class NestThermostat(ClimateDevice): def set_temperature(self, **kwargs): """Set new target temperature.""" + import nest target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if self._mode == NEST_MODE_HEAT_COOL: @@ -170,7 +176,10 @@ class NestThermostat(ClimateDevice): else: temp = kwargs.get(ATTR_TEMPERATURE) _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - self.device.target = temp + try: + self.device.target = temp + except nest.nest.APIError: + _LOGGER.error("An error occured while setting the temperature") def set_operation_mode(self, operation_mode): """Set operation mode.""" @@ -205,11 +214,14 @@ class NestThermostat(ClimateDevice): @property def fan_list(self): """List of available fan modes.""" - return self._fan_list + if self._has_fan: + return self._fan_list + return None def set_fan_mode(self, fan_mode): """Turn fan on/off.""" - self.device.fan = fan_mode.lower() + if self._has_fan: + self.device.fan = fan_mode.lower() @property def min_temp(self): diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 3657b64b989..adf0b8f51b6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -15,12 +15,12 @@ import async_timeout import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE) + EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import dt as dt_util from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import smart_home as ga_sh +from homeassistant.components.google_assistant import helpers as ga_h from . import http_api, iot from .const import CONFIG_DIR, DOMAIN, SERVERS @@ -51,7 +51,6 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({ GOOGLE_ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(ga_sh.MAPPING_COMPONENT), vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]) }) @@ -175,7 +174,7 @@ class Cloud: """If an entity should be exposed.""" return conf['filter'](entity.entity_id) - self._gactions_config = ga_sh.Config( + self._gactions_config = ga_h.Config( should_expose=should_expose, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py index 118a9857158..dcf7567482a 100644 --- a/homeassistant/components/cloud/auth_api.py +++ b/homeassistant/components/cloud/auth_api.py @@ -17,14 +17,6 @@ class UserNotConfirmed(CloudError): """Raised when a user has not confirmed email yet.""" -class ExpiredCode(CloudError): - """Raised when an expired code is encountered.""" - - -class InvalidCode(CloudError): - """Raised when an invalid code is submitted.""" - - class PasswordChangeRequired(CloudError): """Raised when a password change is required.""" @@ -42,10 +34,8 @@ class UnknownError(CloudError): AWS_EXCEPTIONS = { 'UserNotFoundException': UserNotFound, 'NotAuthorizedException': Unauthenticated, - 'ExpiredCodeException': ExpiredCode, 'UserNotConfirmedException': UserNotConfirmed, 'PasswordResetRequiredException': PasswordChangeRequired, - 'CodeMismatchException': InvalidCode, } @@ -69,17 +59,6 @@ def register(cloud, email, password): raise _map_aws_exception(err) -def confirm_register(cloud, confirmation_code, email): - """Confirm confirmation code after registration.""" - from botocore.exceptions import ClientError - - cognito = _cognito(cloud) - try: - cognito.confirm_sign_up(confirmation_code, email) - except ClientError as err: - raise _map_aws_exception(err) - - def resend_email_confirm(cloud, email): """Resend email confirmation.""" from botocore.exceptions import ClientError @@ -107,18 +86,6 @@ def forgot_password(cloud, email): raise _map_aws_exception(err) -def confirm_forgot_password(cloud, confirmation_code, email, new_password): - """Confirm forgotten password code and change password.""" - from botocore.exceptions import ClientError - - cognito = _cognito(cloud, username=email) - - try: - cognito.confirm_forgot_password(confirmation_code, new_password) - except ClientError as err: - raise _map_aws_exception(err) - - def login(cloud, email, password): """Log user in and fetch certificate.""" cognito = _authenticate(cloud, email, password) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f7f327f2f2c..3065de24180 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -23,10 +23,8 @@ def async_setup(hass): hass.http.register_view(CloudLogoutView) hass.http.register_view(CloudAccountView) hass.http.register_view(CloudRegisterView) - hass.http.register_view(CloudConfirmRegisterView) hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) - hass.http.register_view(CloudConfirmForgotPasswordView) _CLOUD_ERRORS = { @@ -34,8 +32,6 @@ _CLOUD_ERRORS = { auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), auth_api.Unauthenticated: (401, 'Authentication failed.'), auth_api.PasswordChangeRequired: (400, 'Password change required.'), - auth_api.ExpiredCode: (400, 'Confirmation code has expired.'), - auth_api.InvalidCode: (400, 'Invalid confirmation code.'), asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') } @@ -149,31 +145,6 @@ class CloudRegisterView(HomeAssistantView): return self.json_message('ok') -class CloudConfirmRegisterView(HomeAssistantView): - """Confirm registration on the Home Assistant cloud.""" - - url = '/api/cloud/confirm_register' - name = 'api:cloud:confirm_register' - - @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('confirmation_code'): str, - vol.Required('email'): str, - })) - @asyncio.coroutine - def post(self, request, data): - """Handle registration confirmation request.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( - auth_api.confirm_register, cloud, data['confirmation_code'], - data['email']) - - return self.json_message('ok') - - class CloudResendConfirmView(HomeAssistantView): """Resend email confirmation code.""" @@ -220,33 +191,6 @@ class CloudForgotPasswordView(HomeAssistantView): return self.json_message('ok') -class CloudConfirmForgotPasswordView(HomeAssistantView): - """View to finish Forgot Password flow..""" - - url = '/api/cloud/confirm_forgot_password' - name = 'api:cloud:confirm_forgot_password' - - @_handle_cloud_errors - @RequestDataValidator(vol.Schema({ - vol.Required('confirmation_code'): str, - vol.Required('email'): str, - vol.Required('new_password'): vol.All(str, vol.Length(min=6)) - })) - @asyncio.coroutine - def post(self, request, data): - """Handle forgot password confirm request.""" - hass = request.app['hass'] - cloud = hass.data[DOMAIN] - - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - yield from hass.async_add_job( - auth_api.confirm_forgot_password, cloud, - data['confirmation_code'], data['email'], - data['new_password']) - - return self.json_message('ok') - - def _account_data(cloud): """Generate the auth data JSON response.""" claims = cloud.claims diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 91fbc85df6b..7cf8e50e866 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -1,6 +1,7 @@ """Module to handle messages from Home Assistant cloud.""" import asyncio import logging +import pprint from aiohttp import hdrs, client_exceptions, WSMsgType @@ -154,7 +155,9 @@ class CloudIoT: disconnect_warn = 'Received invalid JSON.' break - _LOGGER.debug("Received message: %s", msg) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Received message:\n%s\n", + pprint.pformat(msg)) response = { 'msgid': msg['msgid'], @@ -176,7 +179,9 @@ class CloudIoT: _LOGGER.exception("Error handling message") response['error'] = 'exception' - _LOGGER.debug("Publishing message: %s", response) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(response)) yield from client.send_json(response) except client_exceptions.WSServerHandshakeError as err: diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index 515da3e4f54..c40bd99b542 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -14,9 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = [ - 'https://github.com/balloob/coinbase-python/archive/' - '3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1'] +REQUIREMENTS = ['coinbase==2.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 39c35205619..601b12ffe4a 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -13,7 +13,8 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] -SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script') +SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script', + 'entity_registry') ON_DEMAND = ('zwave',) FEATURE_FLAGS = ('config_entries',) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index ebfa095372a..aa42325b75b 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -97,10 +97,10 @@ class ConfigManagerFlowIndexView(HomeAssistantView): flow for flow in hass.config_entries.flow.async_progress() if flow['source'] != config_entries.SOURCE_USER]) - @asyncio.coroutine @RequestDataValidator(vol.Schema({ vol.Required('domain'): str, })) + @asyncio.coroutine def post(self, request, data): """Handle a POST request.""" hass = request.app['hass'] @@ -139,8 +139,8 @@ class ConfigManagerFlowResourceView(HomeAssistantView): return self.json(result) - @asyncio.coroutine @RequestDataValidator(vol.Schema(dict), allow_empty=True) + @asyncio.coroutine def post(self, request, flow_id, data): """Handle a POST request.""" hass = request.app['hass'] @@ -163,7 +163,7 @@ class ConfigManagerFlowResourceView(HomeAssistantView): hass = request.app['hass'] try: - hass.config_entries.async_abort(flow_id) + hass.config_entries.flow.async_abort(flow_id) except config_entries.UnknownFlow: return self.json_message('Invalid flow specified', 404) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py new file mode 100644 index 00000000000..4b9a2c89da0 --- /dev/null +++ b/homeassistant/components/config/entity_registry.py @@ -0,0 +1,55 @@ +"""HTTP views to interact with the entity registry.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.entity_registry import async_get_registry + + +async def async_setup(hass): + """Enable the Entity Registry views.""" + hass.http.register_view(ConfigManagerEntityView) + return True + + +class ConfigManagerEntityView(HomeAssistantView): + """View to interact with an entity registry entry.""" + + url = '/api/config/entity_registry/{entity_id}' + name = 'api:config:entity_registry:entity' + + async def get(self, request, entity_id): + """Get the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + entry = registry.entities.get(entity_id) + + if entry is None: + return self.json_message('Entry not found', 404) + + return self.json(_entry_dict(entry)) + + @RequestDataValidator(vol.Schema({ + # If passed in, we update value. Passing None will remove old value. + vol.Optional('name'): vol.Any(str, None), + })) + async def post(self, request, entity_id, data): + """Update the entity registry settings for an entity.""" + hass = request.app['hass'] + registry = await async_get_registry(hass) + + if entity_id not in registry.entities: + return self.json_message('Entry not found', 404) + + entry = registry.async_update_entity(entity_id, **data) + return self.json(_entry_dict(entry)) + + +@callback +def _entry_dict(entry): + """Helper to convert entry to API format.""" + return { + 'entity_id': entry.entity_id, + 'name': entry.name + } diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py index 9f325f3eb89..e96694ce0a3 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation.py @@ -4,7 +4,6 @@ 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 @@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances): conf.append(_create_matcher(utterance)) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Register the process service.""" config = config.get(DOMAIN, {}) intents = hass.data.get(DOMAIN) @@ -84,49 +82,73 @@ def async_setup(hass, config): conf.extend(_create_matcher(utterance) for utterance in utterances) - @asyncio.coroutine - def process(service): + async def process(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] - yield from _process(hass, text) + try: + await _process(hass, text) + except intent.IntentHandleError as err: + _LOGGER.error('Error processing %s: %s', text, err) hass.services.async_register( DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA) hass.http.register_view(ConversationProcessView) - async_register(hass, intent.INTENT_TURN_ON, - ['Turn {name} on', 'Turn on {name}']) - async_register(hass, intent.INTENT_TURN_OFF, - ['Turn {name} off', 'Turn off {name}']) - async_register(hass, intent.INTENT_TOGGLE, - ['Toggle {name}', '{name} toggle']) + # We strip trailing 's' from name because our state matcher will fail + # if a letter is not there. By removing 's' we can match singular and + # plural names. + + async_register(hass, intent.INTENT_TURN_ON, [ + 'Turn [the] [a] {name}[s] on', + 'Turn on [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TURN_OFF, [ + 'Turn [the] [a] [an] {name}[s] off', + 'Turn off [the] [a] [an] {name}[s]', + ]) + async_register(hass, intent.INTENT_TOGGLE, [ + 'Toggle [the] [a] [an] {name}[s]', + '[the] [a] [an] {name}[s] toggle', + ]) return True def _create_matcher(utterance): """Create a regex that matches the utterance.""" - parts = re.split(r'({\w+})', utterance) + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') pattern = ['^'] - for part in parts: - match = group_matcher.match(part) + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) - if match is None: + # Normal part + if group_match is None and optional_match is None: pattern.append(part) continue - pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+')) + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) pattern.append('$') return re.compile(''.join(pattern), re.I) -@asyncio.coroutine -def _process(hass, text): +async def _process(hass, text): """Process a line of text.""" intents = hass.data.get(DOMAIN, {}) @@ -137,7 +159,7 @@ def _process(hass, text): if not match: continue - response = yield from hass.helpers.intent.async_handle( + response = await hass.helpers.intent.async_handle( DOMAIN, intent_type, {key: {'value': value} for key, value in match.groupdict().items()}, text) @@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView): @RequestDataValidator(vol.Schema({ vol.Required('text'): str, })) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Send a request for processing.""" hass = request.app['hass'] - intent_result = yield from _process(hass, data['text']) + try: + intent_result = await _process(hass, data['text']) + except intent.IntentHandleError as err: + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) if intent_result is None: intent_result = intent.IntentResponse() diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 1dfa0028ab8..b24361d8293 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -150,16 +150,14 @@ def stop_cover_tilt(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for covers.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_cover_service(service): + async def async_handle_cover_service(service): """Handle calls to the cover services.""" covers = component.async_extract_from_service(service) method = SERVICE_TO_METHOD.get(service.service) @@ -169,13 +167,13 @@ def async_setup(hass, config): # call method update_tasks = [] for cover in covers: - yield from getattr(cover, method['method'])(**params) + await getattr(cover, method['method'])(**params) if not cover.should_poll: continue update_tasks.append(cover.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) for service_name in SERVICE_TO_METHOD: schema = SERVICE_TO_METHOD[service_name].get( diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 730a2b29a2e..83668924268 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP covers. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.knx/ """ -import asyncio import voluptuous as vol @@ -50,8 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up cover(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -106,11 +105,10 @@ class KNXCover(CoverDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -147,32 +145,28 @@ class KNXCover(CoverDevice): """Return if the cover is closed.""" return self.device.is_closed() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" if not self.device.is_closed(): - yield from self.device.set_down() + await self.device.set_down() self.start_auto_updater() - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" if not self.device.is_open(): - yield from self.device.set_up() + await self.device.set_up() self.start_auto_updater() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] - yield from self.device.set_position(position) + await self.device.set_position(position) self.start_auto_updater() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the cover.""" - yield from self.device.stop() + await self.device.stop() self.stop_auto_updater() @property @@ -182,12 +176,11 @@ class KNXCover(CoverDevice): return None return self.device.current_angle() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION in kwargs: tilt_position = kwargs[ATTR_TILT_POSITION] - yield from self.device.set_angle(tilt_position) + await self.device.set_angle(tilt_position) def start_auto_updater(self): """Start the autoupdater to update HASS while cover is moving.""" diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index b85c2d9a53b..64ce3cda073 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -118,6 +118,17 @@ def async_setup(hass, config): tasks2 = [] + # Set up history graph + tasks2.append(bootstrap.async_setup_component( + hass, 'history_graph', + {'history_graph': {'switches': { + 'name': 'Recent Switches', + 'entities': switches, + 'hours_to_show': 1, + 'refresh': 60 + }}} + )) + # Set up scripts tasks2.append(bootstrap.async_setup_component( hass, 'script', diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 19ab77350f3..196c11a614f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -77,11 +77,14 @@ ATTR_MAC = 'mac' ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' ATTR_VENDOR = 'vendor' +ATTR_CONSIDER_HOME = 'consider_home' SOURCE_TYPE_GPS = 'gps' SOURCE_TYPE_ROUTER = 'router' SOURCE_TYPE_BLUETOOTH = 'bluetooth' SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le' +SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER, + SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE) NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({ vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, @@ -96,6 +99,19 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NEW_DEVICE_DEFAULTS, default={}): NEW_DEVICE_DEFAULTS_SCHEMA }) +SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All( + cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), { + ATTR_MAC: cv.string, + ATTR_DEV_ID: cv.string, + ATTR_HOST_NAME: cv.string, + ATTR_LOCATION_NAME: cv.string, + ATTR_GPS: cv.gps, + ATTR_GPS_ACCURACY: cv.positive_int, + ATTR_BATTERY: cv.positive_int, + ATTR_ATTRIBUTES: dict, + ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES), + ATTR_CONSIDER_HOME: cv.time_period, + })) @bind_hass @@ -109,7 +125,7 @@ def is_on(hass: HomeAssistantType, entity_id: str = None): def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, host_name: str = None, location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery=None, attributes: dict = None): + battery: int = None, attributes: dict = None): """Call service to notify you see device.""" data = {key: value for key, value in ((ATTR_MAC, mac), @@ -203,12 +219,10 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): @asyncio.coroutine def async_see_service(call): """Service to see a device.""" - args = {key: value for key, value in call.data.items() if key in - (ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME, - ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)} - yield from tracker.async_see(**args) + yield from tracker.async_see(**call.data) - hass.services.async_register(DOMAIN, SERVICE_SEE, async_see_service) + hass.services.async_register( + DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA) # restore yield from tracker.async_setup_tracked_device() @@ -240,23 +254,26 @@ class DeviceTracker(object): dev.mac) def see(self, mac: str = None, dev_id: str = None, host_name: str = None, - location_name: str = None, gps: GPSType = None, gps_accuracy=None, - battery: str = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS, picture: str = None, - icon: str = None): + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device.""" self.hass.add_job( self.async_see(mac, dev_id, host_name, location_name, gps, gps_accuracy, battery, attributes, source_type, - picture, icon) + picture, icon, consider_home) ) @asyncio.coroutine - def async_see(self, mac: str = None, dev_id: str = None, - host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=None, battery: str = None, - attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, - picture: str = None, icon: str = None): + def async_see( + self, mac: str = None, dev_id: str = None, host_name: str = None, + location_name: str = None, gps: GPSType = None, + gps_accuracy: int = None, battery: int = None, + attributes: dict = None, source_type: str = SOURCE_TYPE_GPS, + picture: str = None, icon: str = None, + consider_home: timedelta = None): """Notify the device tracker that you see a device. This method is a coroutine. @@ -275,7 +292,7 @@ class DeviceTracker(object): if device: yield from device.async_seen( host_name, location_name, gps, gps_accuracy, battery, - attributes, source_type) + attributes, source_type, consider_home) if device.track: yield from device.async_update_ha_state() return @@ -283,7 +300,7 @@ class DeviceTracker(object): # If no device can be found, create it dev_id = util.ensure_unique_string(dev_id, self.devices.keys()) device = Device( - self.hass, self.consider_home, self.track_new, + self.hass, consider_home or self.consider_home, self.track_new, dev_id, mac, (host_name or dev_id).replace('_', ' '), picture=picture, icon=icon, hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE)) @@ -384,9 +401,10 @@ class Device(Entity): host_name = None # type: str location_name = None # type: str gps = None # type: GPSType - gps_accuracy = 0 + gps_accuracy = 0 # type: int last_seen = None # type: dt_util.dt.datetime - battery = None # type: str + consider_home = None # type: dt_util.dt.timedelta + battery = None # type: int attributes = None # type: dict vendor = None # type: str icon = None # type: str @@ -476,14 +494,16 @@ class Device(Entity): @asyncio.coroutine def async_seen(self, host_name: str = None, location_name: str = None, - gps: GPSType = None, gps_accuracy=0, battery: str = None, + gps: GPSType = None, gps_accuracy=0, battery: int = None, attributes: dict = None, - source_type: str = SOURCE_TYPE_GPS): + source_type: str = SOURCE_TYPE_GPS, + consider_home: timedelta = None): """Mark the device as seen.""" self.source_type = source_type self.last_seen = dt_util.utcnow() self.host_name = host_name self.location_name = location_name + self.consider_home = consider_home or self.consider_home if battery: self.battery = battery diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 1956e42cb78..14aea561c8e 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -283,15 +283,15 @@ class SshConnection(_Connection): lines = self._ssh.before.split(b'\n')[1:-1] return [line.decode('utf-8') for line in lines] except exceptions.EOF as err: - _LOGGER.error("Connection refused. SSH enabled?") + _LOGGER.error("Connection refused. %s", self._ssh.before) self.disconnect() return None except pxssh.ExceptionPxssh as err: - _LOGGER.error("Unexpected SSH error: %s", str(err)) + _LOGGER.error("Unexpected SSH error: %s", err) self.disconnect() return None except AssertionError as err: - _LOGGER.error("Connection to router unavailable: %s", str(err)) + _LOGGER.error("Connection to router unavailable: %s", err) self.disconnect() return None @@ -301,10 +301,10 @@ class SshConnection(_Connection): self._ssh = pxssh.pxssh() if self._ssh_key: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, ssh_key=self._ssh_key, port=self._port) else: - self._ssh.login(self._host, self._username, + self._ssh.login(self._host, self._username, quiet=False, password=self._password, port=self._port) super().connect() diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 472b48fef6e..781e3674550 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -189,10 +189,12 @@ class Icloud(DeviceScanner): for device in self.api.devices: status = device.status(DEVICESTATUSSET) devicename = slugify(status['name'].replace(' ', '', 99)) - if devicename not in self.devices: - self.devices[devicename] = device - self._intervals[devicename] = 1 - self._overridestates[devicename] = None + if devicename in self.devices: + _LOGGER.error('Multiple devices with name: %s', devicename) + continue + self.devices[devicename] = device + self._intervals[devicename] = 1 + self._overridestates[devicename] = None except PyiCloudNoDevicesException: _LOGGER.error('No iCloud Devices found!') @@ -319,14 +321,6 @@ class Icloud(DeviceScanner): def determine_interval(self, devicename, latitude, longitude, battery): """Calculate new interval.""" - distancefromhome = None - zone_state = self.hass.states.get('zone.home') - zone_state_lat = zone_state.attributes['latitude'] - zone_state_long = zone_state.attributes['longitude'] - distancefromhome = distance( - latitude, longitude, zone_state_lat, zone_state_long) - distancefromhome = round(distancefromhome / 1000, 1) - currentzone = active_zone(self.hass, latitude, longitude) if ((currentzone is not None and @@ -335,22 +329,48 @@ class Icloud(DeviceScanner): self._overridestates.get(devicename) == 'away')): return + zones = (self.hass.states.get(entity_id) for entity_id + in sorted(self.hass.states.entity_ids('zone'))) + + distances = [] + for zone_state in zones: + zone_state_lat = zone_state.attributes['latitude'] + zone_state_long = zone_state.attributes['longitude'] + zone_distance = distance( + latitude, longitude, zone_state_lat, zone_state_long) + distances.append(round(zone_distance / 1000, 1)) + + if distances: + mindistance = min(distances) + else: + mindistance = None + self._overridestates[devicename] = None if currentzone is not None: self._intervals[devicename] = 30 return - if distancefromhome is None: + if mindistance is None: return - if distancefromhome > 25: - self._intervals[devicename] = round(distancefromhome / 2, 0) - elif distancefromhome > 10: - self._intervals[devicename] = 5 - else: - self._intervals[devicename] = 1 - if battery is not None and battery <= 33 and distancefromhome > 3: - self._intervals[devicename] = self._intervals[devicename] * 2 + + # Calculate out how long it would take for the device to drive to the + # nearest zone at 120 km/h: + interval = round(mindistance / 2, 0) + + # Never poll more than once per minute + interval = max(interval, 1) + + if interval > 180: + # Three hour drive? This is far enough that they might be flying + # home - check every half hour + interval = 30 + + if battery is not None and battery <= 33 and mindistance > 3: + # Low battery - let's check half as often + interval = interval * 2 + + self._intervals[devicename] = interval def update_device(self, devicename): """Update the device_tracker entity.""" diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py index 4945e98a94d..ba9bc8c2631 100644 --- a/homeassistant/components/device_tracker/tesla.py +++ b/homeassistant/components/device_tracker/tesla.py @@ -44,14 +44,15 @@ class TeslaDeviceTracker(object): _LOGGER.debug("Updating device position: %s", name) dev_id = slugify(device.uniq_name) location = device.get_location() - lat = location['latitude'] - lon = location['longitude'] - attrs = { - 'trackr_id': dev_id, - 'id': dev_id, - 'name': name - } - self.see( - dev_id=dev_id, host_name=name, - gps=(lat, lon), attributes=attrs - ) + if location: + lat = location['latitude'] + lon = location['longitude'] + attrs = { + 'trackr_id': dev_id, + 'id': dev_id, + 'name': name + } + self.see( + dev_id=dev_id, host_name=name, + gps=(lat, lon), attributes=attrs + ) diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index 946aae5fe56..c75529655f4 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -23,7 +23,8 @@ CONF_DHCP_SOFTWARE = 'dhcp_software' DEFAULT_DHCP_SOFTWARE = 'dnsmasq' DHCP_SOFTWARES = [ 'dnsmasq', - 'odhcpd' + 'odhcpd', + 'none' ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -40,8 +41,10 @@ def get_scanner(hass, config): dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE] if dhcp_sw == 'dnsmasq': scanner = DnsmasqUbusDeviceScanner(config[DOMAIN]) - else: + elif dhcp_sw == 'odhcpd': scanner = OdhcpdUbusDeviceScanner(config[DOMAIN]) + else: + scanner = UbusDeviceScanner(config[DOMAIN]) return scanner if scanner.success_init else None @@ -92,8 +95,8 @@ class UbusDeviceScanner(DeviceScanner): return self.last_results def _generate_mac2name(self): - """Must be implemented depending on the software.""" - raise NotImplementedError + """Return empty MAC to name dict. Overriden if DHCP server is set.""" + self.mac2name = dict() @_refresh_on_access_denied def get_device_name(self, device): diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 980ac7d661c..d1045143bb2 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -71,6 +71,7 @@ SERVICE_HANDLERS = { 'sabnzbd': ('sensor', 'sabnzbd'), 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), + 'songpal': ('media_player', 'songpal'), } CONF_IGNORE = 'ignore' diff --git a/homeassistant/components/egardia.py b/homeassistant/components/egardia.py new file mode 100644 index 00000000000..2cfc44a407b --- /dev/null +++ b/homeassistant/components/egardia.py @@ -0,0 +1,123 @@ +""" +Interfaces with Egardia/Woonveilig alarm control panel. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/egardia/ +""" +import logging + +import requests +import voluptuous as vol + +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, + EVENT_HOMEASSISTANT_STOP) + +REQUIREMENTS = ['pythonegardia==1.0.38'] + +_LOGGER = logging.getLogger(__name__) + +CONF_REPORT_SERVER_CODES = 'report_server_codes' +CONF_REPORT_SERVER_ENABLED = 'report_server_enabled' +CONF_REPORT_SERVER_PORT = 'report_server_port' +REPORT_SERVER_CODES_IGNORE = 'ignore' +CONF_VERSION = 'version' + +DEFAULT_NAME = 'Egardia' +DEFAULT_PORT = 80 +DEFAULT_REPORT_SERVER_ENABLED = False +DEFAULT_REPORT_SERVER_PORT = 52010 +DEFAULT_VERSION = 'GATE-01' +DOMAIN = 'egardia' +EGARDIA_SERVER = 'egardia_server' +EGARDIA_DEVICE = 'egardiadevice' +EGARDIA_NAME = 'egardianame' +EGARDIA_REPORT_SERVER_ENABLED = 'egardia_rs_enabled' +EGARDIA_REPORT_SERVER_CODES = 'egardia_rs_codes' +NOTIFICATION_ID = 'egardia_notification' +NOTIFICATION_TITLE = 'Egardia' +ATTR_DISCOVER_DEVICES = 'egardia_sensor' + +SERVER_CODE_SCHEMA = vol.Schema({ + vol.Optional('arm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('disarm'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('armhome'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('triggered'): vol.All(cv.ensure_list_csv, [cv.string]), + vol.Optional('ignore'): vol.All(cv.ensure_list_csv, [cv.string]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA, + vol.Optional(CONF_REPORT_SERVER_ENABLED, + default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean, + vol.Optional(CONF_REPORT_SERVER_PORT, + default=DEFAULT_REPORT_SERVER_PORT): cv.port, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Egardia platform.""" + from pythonegardia import egardiadevice + from pythonegardia import egardiaserver + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + host = conf.get(CONF_HOST) + port = conf.get(CONF_PORT) + version = conf.get(CONF_VERSION) + rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED) + rs_port = conf.get(CONF_REPORT_SERVER_PORT) + try: + device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( + host, port, username, password, '', version) + except requests.exceptions.RequestException: + _LOGGER.error("An error occurred accessing your Egardia device. " + + "Please check config.") + return False + except egardiadevice.UnauthorizedError: + _LOGGER.error("Unable to authorize. Wrong password or username.") + return False + # Set up the egardia server if enabled + if rs_enabled: + _LOGGER.debug("Setting up EgardiaServer") + try: + if EGARDIA_SERVER not in hass.data: + server = egardiaserver.EgardiaServer('', rs_port) + bound = server.bind() + if not bound: + raise IOError("Binding error occurred while " + + "starting EgardiaServer.") + hass.data[EGARDIA_SERVER] = server + server.start() + + def handle_stop_event(event): + """Callback function for HA stop event.""" + server.stop() + + # listen to home assistant stop event + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) + + except IOError: + _LOGGER.error("Binding error occurred while starting " + + "EgardiaServer.") + return False + + discovery.load_platform(hass, 'alarm_control_panel', DOMAIN, + discovered=conf, hass_config=config) + + # get the sensors from the device and add those + sensors = device.getsensors() + discovery.load_platform(hass, 'binary_sensor', DOMAIN, + {ATTR_DISCOVER_DEVICES: sensors}, config) + + return True diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index c89e4fda358..09ce1a57060 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -4,7 +4,6 @@ Support for local control of entities by emulating the Phillips Hue bridge. For more details about this component, please refer to the documentation at https://home-assistant.io/components/emulated_hue/ """ -import asyncio import logging import voluptuous as vol @@ -111,17 +110,15 @@ def setup(hass, yaml_config): config.upnp_bind_multicast, config.advertise_ip, config.advertise_port) - @asyncio.coroutine - def stop_emulated_hue_bridge(event): + async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_emulated_hue_bridge(event): + async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - yield from server.start() + await server.start() hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) diff --git a/homeassistant/components/fan/insteon_plm.py b/homeassistant/components/fan/insteon_plm.py new file mode 100644 index 00000000000..f30abdbaa30 --- /dev/null +++ b/homeassistant/components/fan/insteon_plm.py @@ -0,0 +1,96 @@ +""" +Support for INSTEON fans via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/fan.insteon_plm/ +""" +import asyncio +import logging + +from homeassistant.components.fan import (SPEED_OFF, + SPEED_LOW, + SPEED_MEDIUM, + SPEED_HIGH, + FanEntity, + SUPPORT_SET_SPEED) +from homeassistant.const import STATE_OFF +from homeassistant.components.insteon_plm import InsteonPLMEntity + +DEPENDENCIES = ['insteon_plm'] + +SPEED_TO_HEX = {SPEED_OFF: 0x00, + SPEED_LOW: 0x3f, + SPEED_MEDIUM: 0xbe, + SPEED_HIGH: 0xff} + +FAN_SPEEDS = [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] + + _LOGGER.debug('Adding device %s entity %s to Fan platform', + device.address.hex, device.states[state_key].name) + + new_entity = InsteonPLMFan(device, state_key) + + async_add_devices([new_entity]) + + +class InsteonPLMFan(InsteonPLMEntity, FanEntity): + """An INSTEON fan component.""" + + @property + def speed(self) -> str: + """Return the current speed.""" + return self._hex_to_speed(self._insteon_device_state.value) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return FAN_SPEEDS + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return SUPPORT_SET_SPEED + + @asyncio.coroutine + def async_turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the entity.""" + if speed is None: + speed = SPEED_MEDIUM + yield from self.async_set_speed(speed) + + @asyncio.coroutine + def async_turn_off(self, **kwargs) -> None: + """Turn off the entity.""" + yield from self.async_set_speed(SPEED_OFF) + + @asyncio.coroutine + def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + fan_speed = SPEED_TO_HEX[speed] + if fan_speed == 0x00: + self._insteon_device_state.off() + else: + self._insteon_device_state.set_level(fan_speed) + + @staticmethod + def _hex_to_speed(speed: int): + hex_speed = SPEED_OFF + if speed > 0xfe: + hex_speed = SPEED_HIGH + elif speed > 0x7f: + hex_speed = SPEED_MEDIUM + elif speed > 0: + hex_speed = SPEED_LOW + return hex_speed diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3863a4d390b..501537e3ed3 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,9 +21,10 @@ from homeassistant.components.http.const import KEY_AUTHENTICATED 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.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180227.0', 'user-agents==1.1.0'] +REQUIREMENTS = ['home-assistant-frontend==20180309.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] @@ -379,6 +380,8 @@ def async_setup(hass, config): async_setup_themes(hass, conf.get(CONF_THEMES)) + hass.http.register_view(TranslationsView) + return True @@ -541,6 +544,23 @@ class ThemesView(HomeAssistantView): }) +class TranslationsView(HomeAssistantView): + """View to return backend defined translations.""" + + url = '/api/translations/{language}' + name = 'api:translations' + + @asyncio.coroutine + def get(self, request, language): + """Return translations.""" + hass = request.app['hass'] + + resources = yield from async_get_translations(hass, language) + return self.json({ + 'resources': resources, + }) + + def _fingerprint(path): """Fingerprint a file.""" with open(path) as fil: @@ -553,6 +573,8 @@ def _is_latest(js_option, request): Set according to user's preference and URL override. """ + import hass_frontend + if request is None: return js_option == 'latest' @@ -573,25 +595,5 @@ def _is_latest(js_option, request): return js_option == 'latest' useragent = request.headers.get('User-Agent') - if not useragent: - return False - from user_agents import parse - useragent = parse(useragent) - - # on iOS every browser is a Safari which we support from version 11. - if useragent.os.family == 'iOS': - # Was >= 10, temp setting it to 12 to work around issue #11387 - return useragent.os.version[0] >= 12 - - family_min_version = { - 'Chrome': 54, # Object.values - 'Chrome Mobile': 54, - 'Firefox': 47, # Object.values - 'Firefox Mobile': 47, - 'Opera': 41, # Object.values - 'Edge': 14, # Array.prototype.includes added in 14 - 'Safari': 10, # Many features not supported by 9 - } - version = family_min_version.get(useragent.browser.family) - return version and useragent.browser.version[0] >= version + return useragent and hass_frontend.version(useragent) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 20dee082a08..676654c2c91 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.core import HomeAssistant # NOQA from typing import Dict, Any # NOQA -from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.loader import bind_hass @@ -31,7 +31,6 @@ from .const import ( ) from .auth import GoogleAssistantAuthView from .http import async_register_http -from .smart_home import MAPPING_COMPONENT _LOGGER = logging.getLogger(__name__) @@ -41,7 +40,6 @@ DEFAULT_AGENT_USER_ID = 'home-assistant' ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT), vol.Optional(CONF_EXPOSE): cv.boolean, vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_ROOM_HINT): cv.string diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 1f1ae4682b4..12888ea2cf6 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -22,25 +22,6 @@ DEFAULT_EXPOSED_DOMAINS = [ CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} -PREFIX_TRAITS = 'action.devices.traits.' -TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' -TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' -TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum' -TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' -TRAIT_SCENE = PREFIX_TRAITS + 'Scene' -TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' - -PREFIX_COMMANDS = 'action.devices.commands.' -COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' -COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute' -COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute' -COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene' -COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') -COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( - PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') -COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' - PREFIX_TYPES = 'action.devices.types.' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' TYPE_SWITCH = PREFIX_TYPES + 'SWITCH' @@ -50,3 +31,12 @@ TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync' + +# Error codes used for SmartHomeError class +# https://developers.google.com/actions/smarthome/create-app#error_responses +ERR_DEVICE_OFFLINE = "deviceOffline" +ERR_DEVICE_NOT_FOUND = "deviceNotFound" +ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" +ERR_NOT_SUPPORTED = "notSupported" +ERR_PROTOCOL_ERROR = 'protocolError' +ERR_UNKNOWN_ERROR = 'unknownError' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py new file mode 100644 index 00000000000..ef6ae109eb5 --- /dev/null +++ b/homeassistant/components/google_assistant/helpers.py @@ -0,0 +1,23 @@ +"""Helper classes for Google Assistant integration.""" + + +class SmartHomeError(Exception): + """Google Assistant Smart Home errors. + + https://developers.google.com/actions/smarthome/create-app#error_responses + """ + + def __init__(self, code, msg): + """Log error code.""" + super().__init__(msg) + self.code = code + + +class Config: + """Hold the configuration for Google Assistant.""" + + def __init__(self, should_expose, agent_user_id, entity_config=None): + """Initialize the configuration.""" + self.should_expose = should_expose + self.agent_user_id = agent_user_id + self.entity_config = entity_config or {} diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f376435d2ef..0caea3aadf4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -10,8 +10,6 @@ import logging from aiohttp.hdrs import AUTHORIZATION from aiohttp.web import Request, Response # NOQA -from homeassistant.const import HTTP_UNAUTHORIZED - # Typing imports # pylint: disable=using-constant-test,unused-import,ungrouped-imports from homeassistant.components.http import HomeAssistantView @@ -27,7 +25,8 @@ from .const import ( CONF_ENTITY_CONFIG, CONF_EXPOSE, ) -from .smart_home import async_handle_message, Config +from .smart_home import async_handle_message +from .helpers import Config _LOGGER = logging.getLogger(__name__) @@ -83,8 +82,7 @@ class GoogleAssistantView(HomeAssistantView): """Handle Google Assistant requests.""" auth = request.headers.get(AUTHORIZATION, None) if 'Bearer {}'.format(self.access_token) != auth: - return self.json_message( - "missing authorization", status_code=HTTP_UNAUTHORIZED) + return self.json_message("missing authorization", status_code=401) message = yield from request.json() # type: dict result = yield from async_handle_message( diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index f638b847bcb..48d24c00b97 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,5 +1,6 @@ """Support for Google Assistant Smart Home API.""" -import asyncio +import collections +from itertools import product import logging # Typing imports @@ -9,447 +10,222 @@ from aiohttp.web import Request, Response # NOQA from typing import Dict, Tuple, Any, Optional # NOQA from homeassistant.helpers.entity import Entity # NOQA from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util import color from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry +from homeassistant.core import callback from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, - STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON, - TEMP_FAHRENHEIT, TEMP_CELSIUS, - CONF_NAME, CONF_TYPE -) + CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) from homeassistant.components import ( switch, light, cover, media_player, group, fan, scene, script, climate, - sensor ) -from homeassistant.util.unit_system import METRIC_SYSTEM +from . import trait from .const import ( - COMMAND_COLOR, - COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE, - COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE, - TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP, - TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING, TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT, - CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES, - CLIMATE_MODE_HEATCOOL + CONF_ALIASES, CONF_ROOM_HINT, + ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_UNKNOWN_ERROR ) +from .helpers import SmartHomeError HANDLERS = Registry() -QUERY_HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -# Mapping is [actions schema, primary trait, optional features] -# optional is SUPPORT_* = (trait, command) -MAPPING_COMPONENT = { - group.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - script.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None], - switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None], - light.DOMAIN: [ - TYPE_LIGHT, TRAIT_ONOFF, { - light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS, - light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR, - light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP, +DOMAIN_TO_GOOGLE_TYPES = { + group.DOMAIN: TYPE_SWITCH, + scene.DOMAIN: TYPE_SCENE, + script.DOMAIN: TYPE_SCENE, + switch.DOMAIN: TYPE_SWITCH, + fan.DOMAIN: TYPE_SWITCH, + light.DOMAIN: TYPE_LIGHT, + cover.DOMAIN: TYPE_SWITCH, + media_player.DOMAIN: TYPE_SWITCH, + climate.DOMAIN: TYPE_THERMOSTAT, +} + + +def deep_update(target, source): + """Update a nested dictionary with another nested dictionary.""" + for key, value in source.items(): + if isinstance(value, collections.Mapping): + target[key] = deep_update(target.get(key, {}), value) + else: + target[key] = value + return target + + +class _GoogleEntity: + """Adaptation of Entity expressed in Google's terms.""" + + def __init__(self, hass, config, state): + self.hass = hass + self.config = config + self.state = state + + @property + def entity_id(self): + """Return entity ID.""" + return self.state.entity_id + + @callback + def traits(self): + """Return traits for entity.""" + state = self.state + domain = state.domain + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + return [Trait(state) for Trait in trait.TRAITS + if Trait.supported(domain, features)] + + @callback + def sync_serialize(self): + """Serialize entity for a SYNC response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ + traits = self.traits() + state = self.state + + # Found no supported traits for this entity + if not traits: + return None + + entity_config = self.config.entity_config.get(state.entity_id, {}) + + device = { + 'id': state.entity_id, + 'name': { + 'name': entity_config.get(CONF_NAME) or state.name + }, + 'attributes': {}, + 'traits': [trait.name for trait in traits], + 'willReportState': False, + 'type': DOMAIN_TO_GOOGLE_TYPES[state.domain], } - ], - cover.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS - } - ], - media_player.DOMAIN: [ - TYPE_SWITCH, TRAIT_ONOFF, { - media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS - } - ], - climate.DOMAIN: [TYPE_THERMOSTAT, TRAIT_TEMPERATURE_SETTING, None], -} # type: Dict[str, list] + + # use aliases + aliases = entity_config.get(CONF_ALIASES) + if aliases: + device['name']['nicknames'] = aliases + + # add room hint if annotated + room = entity_config.get(CONF_ROOM_HINT) + if room: + device['roomHint'] = room + + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + + return device + + @callback + def query_serialize(self): + """Serialize entity for a QUERY response. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ + state = self.state + + if state.state == STATE_UNAVAILABLE: + return {'online': False} + + attrs = {'online': True} + + for trt in self.traits(): + deep_update(attrs, trt.query_attributes()) + + return attrs + + async def execute(self, command, params): + """Execute a command. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + executed = False + for trt in self.traits(): + if trt.can_execute(command, params): + await trt.execute(self.hass, command, params) + executed = True + break + + if not executed: + raise SmartHomeError( + ERR_NOT_SUPPORTED, + 'Unable to execute {} for {}'.format(command, + self.state.entity_id)) + + @callback + def async_update(self): + """Update the entity with latest info from Home Assistant.""" + self.state = self.hass.states.get(self.entity_id) -"""Error code used for SmartHomeError class.""" -ERROR_NOT_SUPPORTED = "notSupported" +async def async_handle_message(hass, config, message): + """Handle incoming API messages.""" + response = await _process(hass, config, message) - -class SmartHomeError(Exception): - """Google Assistant Smart Home errors.""" - - def __init__(self, code, msg): - """Log error code.""" - super(SmartHomeError, self).__init__(msg) - _LOGGER.error( - "An error has occurred in Google SmartHome: %s." - "Error code: %s", msg, code - ) - self.code = code - - -class Config: - """Hold the configuration for Google Assistant.""" - - def __init__(self, should_expose, agent_user_id, entity_config=None): - """Initialize the configuration.""" - self.should_expose = should_expose - self.agent_user_id = agent_user_id - self.entity_config = entity_config or {} - - -def entity_to_device(entity: Entity, config: Config, units: UnitSystem): - """Convert a hass entity into a google actions device.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - class_data = MAPPING_COMPONENT.get( - google_domain or entity.domain) - - if class_data is None: - return None - - device = { - 'id': entity.entity_id, - 'name': {}, - 'attributes': {}, - 'traits': [], - 'willReportState': False, - } - device['type'] = class_data[0] - device['traits'].append(class_data[1]) - - # handle custom names - device['name']['name'] = entity_config.get(CONF_NAME) or entity.name - - # use aliases - aliases = entity_config.get(CONF_ALIASES) - if aliases: - device['name']['nicknames'] = aliases - - # add room hint if annotated - room = entity_config.get(CONF_ROOM_HINT) - if room: - device['roomHint'] = room - - # add trait if entity supports feature - if class_data[2]: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - for feature, trait in class_data[2].items(): - if feature & supported > 0: - device['traits'].append(trait) - - # Actions require this attributes for a device - # supporting temperature - # For IKEA trådfri, these attributes only seem to - # be set only if the device is on? - if trait == TRAIT_COLOR_TEMP: - if entity.attributes.get( - light.ATTR_MAX_MIREDS) is not None: - device['attributes']['temperatureMinK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MAX_MIREDS)))) - if entity.attributes.get( - light.ATTR_MIN_MIREDS) is not None: - device['attributes']['temperatureMaxK'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_MIN_MIREDS)))) - - if entity.domain == climate.DOMAIN: - modes = [] - for mode in entity.attributes.get(climate.ATTR_OPERATION_LIST, []): - if mode in CLIMATE_SUPPORTED_MODES: - modes.append(mode) - elif mode == climate.STATE_AUTO: - modes.append(CLIMATE_MODE_HEATCOOL) - - device['attributes'] = { - 'availableThermostatModes': ','.join(modes), - 'thermostatTemperatureUnit': - 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Thermostat attributes %s', device['attributes']) - - if entity.domain == sensor.DOMAIN: - if google_domain == climate.DOMAIN: - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - - device['attributes'] = { - 'thermostatTemperatureUnit': - 'F' if unit_of_measurement == TEMP_FAHRENHEIT else 'C', - } - _LOGGER.debug('Sensor attributes %s', device['attributes']) - - return device - - -def celsius(deg: Optional[float], units: UnitSystem) -> Optional[float]: - """Convert a float to Celsius and rounds to one decimal place.""" - if deg is None: - return None - return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) - - -@QUERY_HANDLERS.register(sensor.DOMAIN) -def query_response_sensor( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a sensor entity to a QUERY response.""" - entity_config = config.entity_config.get(entity.entity_id, {}) - google_domain = entity_config.get(CONF_TYPE) - - if google_domain != climate.DOMAIN: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Sensor type {} is not supported".format(google_domain) - ) - - # check if we have a string value to convert it to number - value = entity.state - if isinstance(entity.state, str): - try: - value = float(value) - except ValueError: - value = None - - if value is None: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Invalid value {} for the climate sensor" - .format(entity.state) - ) - - # detect if we report temperature or humidity - unit_of_measurement = entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, - units.temperature_unit - ) - if unit_of_measurement in [TEMP_FAHRENHEIT, TEMP_CELSIUS]: - value = celsius(value, units) - attr = 'thermostatTemperatureAmbient' - elif unit_of_measurement == '%': - attr = 'thermostatHumidityAmbient' - else: - raise SmartHomeError( - ERROR_NOT_SUPPORTED, - "Unit {} is not supported by the climate sensor" - .format(unit_of_measurement) - ) - - return {attr: value} - - -@QUERY_HANDLERS.register(climate.DOMAIN) -def query_response_climate( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a climate entity to a QUERY response.""" - mode = entity.attributes.get(climate.ATTR_OPERATION_MODE) - if mode is None: - mode = entity.state - mode = mode.lower() - if mode not in CLIMATE_SUPPORTED_MODES: - mode = 'heat' - attrs = entity.attributes - response = { - 'thermostatMode': mode, - 'thermostatTemperatureSetpoint': - celsius(attrs.get(climate.ATTR_TEMPERATURE), units), - 'thermostatTemperatureAmbient': - celsius(attrs.get(climate.ATTR_CURRENT_TEMPERATURE), units), - 'thermostatTemperatureSetpointHigh': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_HIGH), units), - 'thermostatTemperatureSetpointLow': - celsius(attrs.get(climate.ATTR_TARGET_TEMP_LOW), units), - 'thermostatHumidityAmbient': - attrs.get(climate.ATTR_CURRENT_HUMIDITY), - } - return {k: v for k, v in response.items() if v is not None} - - -@QUERY_HANDLERS.register(media_player.DOMAIN) -def query_response_media_player( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a media_player entity to a QUERY response.""" - level = entity.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL, - 1.0 if entity.state != STATE_OFF else 0.0) - # Convert 0.0-1.0 to 0-255 - brightness = int(level * 100) - - return {'brightness': brightness} - - -@QUERY_HANDLERS.register(light.DOMAIN) -def query_response_light( - entity: Entity, config: Config, units: UnitSystem) -> dict: - """Convert a light entity to a QUERY response.""" - response = {} # type: Dict[str, Any] - - brightness = entity.attributes.get(light.ATTR_BRIGHTNESS) - if brightness is not None: - response['brightness'] = int(100 * (brightness / 255)) - - supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported_features & \ - (light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR): - response['color'] = {} - - if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None: - response['color']['temperature'] = \ - int(round(color.color_temperature_mired_to_kelvin( - entity.attributes.get(light.ATTR_COLOR_TEMP)))) - - if entity.attributes.get(light.ATTR_COLOR_NAME) is not None: - response['color']['name'] = \ - entity.attributes.get(light.ATTR_COLOR_NAME) - - if entity.attributes.get(light.ATTR_RGB_COLOR) is not None: - color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR) - if color_rgb is not None: - response['color']['spectrumRGB'] = \ - int(color.color_rgb_to_hex( - color_rgb[0], color_rgb[1], color_rgb[2]), 16) + if 'errorCode' in response['payload']: + _LOGGER.error('Error handling message %s: %s', + message, response['payload']) return response -def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict: - """Take an entity and return a properly formatted device object.""" - state = entity.state != STATE_OFF - defaults = { - 'on': state, - 'online': True - } - - handler = QUERY_HANDLERS.get(entity.domain) - if callable(handler): - defaults.update(handler(entity, config, units)) - - return defaults - - -# erroneous bug on old pythons and pylint -# https://github.com/PyCQA/pylint/issues/1212 -# pylint: disable=invalid-sequence-index -def determine_service( - entity_id: str, command: str, params: dict, - units: UnitSystem) -> Tuple[str, dict]: - """ - Determine service and service_data. - - Attempt to return a tuple of service and service_data based on the entity - and action requested. - """ - _LOGGER.debug("Handling command %s with data %s", command, params) - domain = entity_id.split('.')[0] - service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any] - # special media_player handling - if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness', 0) - service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100 - return (media_player.SERVICE_VOLUME_SET, service_data) - - # special cover handling - if domain == cover.DOMAIN: - if command == COMMAND_BRIGHTNESS: - service_data['position'] = params.get('brightness', 0) - return (cover.SERVICE_SET_COVER_POSITION, service_data) - if command == COMMAND_ONOFF and params.get('on') is True: - return (cover.SERVICE_OPEN_COVER, service_data) - return (cover.SERVICE_CLOSE_COVER, service_data) - - # special climate handling - if domain == climate.DOMAIN: - if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - service_data['temperature'] = \ - units.temperature( - params['thermostatTemperatureSetpoint'], TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: - service_data['target_temp_high'] = units.temperature( - params.get('thermostatTemperatureSetpointHigh', 25), - TEMP_CELSIUS) - service_data['target_temp_low'] = units.temperature( - params.get('thermostatTemperatureSetpointLow', 18), - TEMP_CELSIUS) - return (climate.SERVICE_SET_TEMPERATURE, service_data) - if command == COMMAND_THERMOSTAT_SET_MODE: - mode = params['thermostatMode'] - - if mode == CLIMATE_MODE_HEATCOOL: - mode = climate.STATE_AUTO - - service_data['operation_mode'] = mode - return (climate.SERVICE_SET_OPERATION_MODE, service_data) - - if command == COMMAND_BRIGHTNESS: - brightness = params.get('brightness') - service_data['brightness'] = int(brightness / 100 * 255) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_COLOR: - color_data = params.get('color') - if color_data is not None: - if color_data.get('temperature', 0) > 0: - service_data[light.ATTR_KELVIN] = color_data.get('temperature') - return (SERVICE_TURN_ON, service_data) - if color_data.get('spectrumRGB', 0) > 0: - # blue is 255 so pad up to 6 chars - hex_value = \ - ('%0x' % int(color_data.get('spectrumRGB'))).zfill(6) - service_data[light.ATTR_RGB_COLOR] = \ - color.rgb_hex_to_rgb_list(hex_value) - return (SERVICE_TURN_ON, service_data) - - if command == COMMAND_ACTIVATESCENE: - return (SERVICE_TURN_ON, service_data) - - if COMMAND_ONOFF == command: - if params.get('on') is True: - return (SERVICE_TURN_ON, service_data) - return (SERVICE_TURN_OFF, service_data) - - return (None, service_data) - - -@asyncio.coroutine -def async_handle_message(hass, config, message): - """Handle incoming API messages.""" +async def _process(hass, config, message): + """Process a message.""" request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list - if len(inputs) > 1: - _LOGGER.warning('Got unexpected more than 1 input. %s', message) + if len(inputs) != 1: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - # Only use first input - intent = inputs[0].get('intent') - payload = inputs[0].get('payload') + handler = HANDLERS.get(inputs[0].get('intent')) - handler = HANDLERS.get(intent) + if handler is None: + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_PROTOCOL_ERROR} + } - if handler: - result = yield from handler(hass, config, payload) - else: - result = {'errorCode': 'protocolError'} - - return {'requestId': request_id, 'payload': result} + try: + result = await handler(hass, config, inputs[0].get('payload')) + return {'requestId': request_id, 'payload': result} + except SmartHomeError as err: + return { + 'requestId': request_id, + 'payload': {'errorCode': err.code} + } + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception('Unexpected error') + return { + 'requestId': request_id, + 'payload': {'errorCode': ERR_UNKNOWN_ERROR} + } @HANDLERS.register('action.devices.SYNC') -@asyncio.coroutine -def async_devices_sync(hass, config: Config, payload): - """Handle action.devices.SYNC request.""" +async def async_devices_sync(hass, config, payload): + """Handle action.devices.SYNC request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicessync + """ devices = [] - for entity in hass.states.async_all(): - if not config.should_expose(entity): + for state in hass.states.async_all(): + if not config.should_expose(state): continue - device = entity_to_device(entity, config, hass.config.units) - if device is None: - _LOGGER.warning("No mapping for %s domain", entity.domain) + entity = _GoogleEntity(hass, config, state) + serialized = entity.sync_serialize() + + if serialized is None: + _LOGGER.debug("No mapping for %s domain", entity.state) continue - devices.append(device) + devices.append(serialized) return { 'agentUserId': config.agent_user_id, @@ -458,53 +234,79 @@ def async_devices_sync(hass, config: Config, payload): @HANDLERS.register('action.devices.QUERY') -@asyncio.coroutine -def async_devices_query(hass, config, payload): - """Handle action.devices.QUERY request.""" +async def async_devices_query(hass, config, payload): + """Handle action.devices.QUERY request. + + https://developers.google.com/actions/smarthome/create-app#actiondevicesquery + """ devices = {} for device in payload.get('devices', []): - devid = device.get('id') - # In theory this should never happen - if not devid: - _LOGGER.error('Device missing ID: %s', device) - continue - + devid = device['id'] state = hass.states.get(devid) + if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} - else: - try: - devices[devid] = query_device(state, config, hass.config.units) - except SmartHomeError as error: - devices[devid] = {'errorCode': error.code} + continue + + devices[devid] = _GoogleEntity(hass, config, state).query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -@asyncio.coroutine -def handle_devices_execute(hass, config, payload): - """Handle action.devices.EXECUTE request.""" - commands = [] - for command in payload.get('commands', []): - ent_ids = [ent.get('id') for ent in command.get('devices', [])] - for execution in command.get('execution'): - for eid in ent_ids: - success = False - domain = eid.split('.')[0] - (service, service_data) = determine_service( - eid, execution.get('command'), execution.get('params'), - hass.config.units) - if domain == "group": - domain = "homeassistant" - success = yield from hass.services.async_call( - domain, service, service_data, blocking=True) - result = {"ids": [eid], "states": {}} - if success: - result['status'] = 'SUCCESS' - else: - result['status'] = 'ERROR' - commands.append(result) +async def handle_devices_execute(hass, config, payload): + """Handle action.devices.EXECUTE request. - return {'commands': commands} + https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute + """ + entities = {} + results = {} + + for command in payload['commands']: + for device, execution in product(command['devices'], + command['execution']): + entity_id = device['id'] + + # Happens if error occurred. Skip entity for further processing + if entity_id in results: + continue + + if entity_id not in entities: + state = hass.states.get(entity_id) + + if state is None: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': ERR_DEVICE_OFFLINE + } + continue + + entities[entity_id] = _GoogleEntity(hass, config, state) + + try: + await entities[entity_id].execute(execution['command'], + execution.get('params', {})) + except SmartHomeError as err: + results[entity_id] = { + 'ids': [entity_id], + 'status': 'ERROR', + 'errorCode': err.code + } + + final_results = list(results.values()) + + for entity in entities.values(): + if entity.entity_id in results: + continue + + entity.async_update() + + final_results.append({ + 'ids': [entity.entity_id], + 'status': 'SUCCESS', + 'states': entity.query_serialize(), + }) + + return {'commands': final_results} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py new file mode 100644 index 00000000000..dd7b761e782 --- /dev/null +++ b/homeassistant/components/google_assistant/trait.py @@ -0,0 +1,521 @@ +"""Implement the Smart Home traits.""" +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + group, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util import color as color_util, temperature as temp_util + +from .const import ERR_VALUE_OUT_OF_RANGE +from .helpers import SmartHomeError + +PREFIX_TRAITS = 'action.devices.traits.' +TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff' +TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness' +TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' +TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' +TRAIT_SCENE = PREFIX_TRAITS + 'Scene' +TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' + +PREFIX_COMMANDS = 'action.devices.commands.' +COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' +COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute' +COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute' +COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene' +COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint') +COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( + PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') +COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' + + +TRAITS = [] + + +def register_trait(trait): + """Decorator to register a trait.""" + TRAITS.append(trait) + return trait + + +def _google_temp_unit(state): + """Return Google temperature unit.""" + if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == + TEMP_FAHRENHEIT): + return 'F' + return 'C' + + +class _Trait: + """Represents a Trait inside Google Assistant skill.""" + + commands = [] + + def __init__(self, state): + """Initialize a trait for a state.""" + self.state = state + + def sync_attributes(self): + """Return attributes for a sync request.""" + raise NotImplementedError + + def query_attributes(self): + """Return the attributes of this trait for this entity.""" + raise NotImplementedError + + def can_execute(self, command, params): + """Test if command can be executed.""" + return command in self.commands + + async def execute(self, hass, command, params): + """Execute a trait command.""" + raise NotImplementedError + + +@register_trait +class BrightnessTrait(_Trait): + """Trait to control brightness of a device. + + https://developers.google.com/actions/smarthome/traits/brightness + """ + + name = TRAIT_BRIGHTNESS + commands = [ + COMMAND_BRIGHTNESS_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain == light.DOMAIN: + return features & light.SUPPORT_BRIGHTNESS + elif domain == cover.DOMAIN: + return features & cover.SUPPORT_SET_POSITION + elif domain == media_player.DOMAIN: + return features & media_player.SUPPORT_VOLUME_SET + + return False + + def sync_attributes(self): + """Return brightness attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return brightness query attributes.""" + domain = self.state.domain + response = {} + + if domain == light.DOMAIN: + brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) + if brightness is not None: + response['brightness'] = int(100 * (brightness / 255)) + + elif domain == cover.DOMAIN: + position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION) + if position is not None: + response['brightness'] = position + + elif domain == media_player.DOMAIN: + level = self.state.attributes.get( + media_player.ATTR_MEDIA_VOLUME_LEVEL) + if level is not None: + # Convert 0.0-1.0 to 0-255 + response['brightness'] = int(level * 100) + + return response + + async def execute(self, hass, command, params): + """Execute a brightness command.""" + domain = self.state.domain + + if domain == light.DOMAIN: + await hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_BRIGHTNESS_PCT: params['brightness'] + }, blocking=True) + elif domain == cover.DOMAIN: + await hass.services.async_call( + cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { + ATTR_ENTITY_ID: self.state.entity_id, + cover.ATTR_POSITION: params['brightness'] + }, blocking=True) + elif domain == media_player.DOMAIN: + await hass.services.async_call( + media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_MEDIA_VOLUME_LEVEL: + params['brightness'] / 100 + }, blocking=True) + + +@register_trait +class OnOffTrait(_Trait): + """Trait to offer basic on and off functionality. + + https://developers.google.com/actions/smarthome/traits/onoff + """ + + name = TRAIT_ONOFF + commands = [ + COMMAND_ONOFF + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in ( + group.DOMAIN, + switch.DOMAIN, + fan.DOMAIN, + light.DOMAIN, + cover.DOMAIN, + media_player.DOMAIN, + ) + + def sync_attributes(self): + """Return OnOff attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return OnOff query attributes.""" + if self.state.domain == cover.DOMAIN: + return {'on': self.state.state != cover.STATE_CLOSED} + return {'on': self.state.state != STATE_OFF} + + async def execute(self, hass, command, params): + """Execute an OnOff command.""" + domain = self.state.domain + + if domain == cover.DOMAIN: + service_domain = domain + if params['on']: + service = cover.SERVICE_OPEN_COVER + else: + service = cover.SERVICE_CLOSE_COVER + + elif domain == group.DOMAIN: + service_domain = HA_DOMAIN + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + else: + service_domain = domain + service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF + + await hass.services.async_call(service_domain, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) + + +@register_trait +class ColorSpectrumTrait(_Trait): + """Trait to offer color spectrum functionality. + + https://developers.google.com/actions/smarthome/traits/colorspectrum + """ + + name = TRAIT_COLOR_SPECTRUM + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & (light.SUPPORT_RGB_COLOR | light.SUPPORT_XY_COLOR) + + def sync_attributes(self): + """Return color spectrum attributes for a sync request.""" + # Other colorModel is hsv + return {'colorModel': 'rgb'} + + def query_attributes(self): + """Return color spectrum query attributes.""" + response = {} + + # No need to handle XY color because light component will always + # convert XY to RGB if possible (which is when brightness is available) + color_rgb = self.state.attributes.get(light.ATTR_RGB_COLOR) + if color_rgb is not None: + response['color'] = { + 'spectrumRGB': int(color_util.color_rgb_to_hex( + color_rgb[0], color_rgb[1], color_rgb[2]), 16), + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'spectrumRGB' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color spectrum command.""" + # Convert integer to hex format and left pad with 0's till length 6 + hex_value = "{0:06x}".format(params['color']['spectrumRGB']) + color = color_util.rgb_hex_to_rgb_list(hex_value) + + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_RGB_COLOR: color + }, blocking=True) + + +@register_trait +class ColorTemperatureTrait(_Trait): + """Trait to offer color temperature functionality. + + https://developers.google.com/actions/smarthome/traits/colortemperature + """ + + name = TRAIT_COLOR_TEMP + commands = [ + COMMAND_COLOR_ABSOLUTE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != light.DOMAIN: + return False + + return features & light.SUPPORT_COLOR_TEMP + + def sync_attributes(self): + """Return color temperature attributes for a sync request.""" + attrs = self.state.attributes + return { + 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS)), + 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MAX_MIREDS)), + } + + def query_attributes(self): + """Return color temperature query attributes.""" + response = {} + + temp = self.state.attributes.get(light.ATTR_COLOR_TEMP) + if temp is not None: + response['color'] = { + 'temperature': + color_util.color_temperature_mired_to_kelvin(temp) + } + + return response + + def can_execute(self, command, params): + """Test if command can be executed.""" + return (command in self.commands and + 'temperature' in params.get('color', {})) + + async def execute(self, hass, command, params): + """Execute a color temperature command.""" + temp = color_util.color_temperature_kelvin_to_mired( + params['color']['temperature']) + min_temp = self.state.attributes[light.ATTR_MIN_MIREDS] + max_temp = self.state.attributes[light.ATTR_MAX_MIREDS] + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id, + light.ATTR_COLOR_TEMP: temp, + }, blocking=True) + + +@register_trait +class SceneTrait(_Trait): + """Trait to offer scene functionality. + + https://developers.google.com/actions/smarthome/traits/scene + """ + + name = TRAIT_SCENE + commands = [ + COMMAND_ACTIVATE_SCENE + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain in (scene.DOMAIN, script.DOMAIN) + + def sync_attributes(self): + """Return scene attributes for a sync request.""" + # Neither supported domain can support sceneReversible + return {} + + def query_attributes(self): + """Return scene query attributes.""" + return {} + + async def execute(self, hass, command, params): + """Execute a scene command.""" + # Don't block for scripts as they can be slow. + await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=self.state.domain != script.DOMAIN) + + +@register_trait +class TemperatureSettingTrait(_Trait): + """Trait to offer handling both temperature point and modes functionality. + + https://developers.google.com/actions/smarthome/traits/temperaturesetting + """ + + name = TRAIT_TEMPERATURE_SETTING + commands = [ + COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, + COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, + COMMAND_THERMOSTAT_SET_MODE, + ] + # We do not support "on" as we are unable to know how to restore + # the last mode. + hass_to_google = { + climate.STATE_HEAT: 'heat', + climate.STATE_COOL: 'cool', + climate.STATE_OFF: 'off', + climate.STATE_AUTO: 'heatcool', + } + google_to_hass = {value: key for key, value in hass_to_google.items()} + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != climate.DOMAIN: + return False + + return features & climate.SUPPORT_OPERATION_MODE + + def sync_attributes(self): + """Return temperature point and modes attributes for a sync request.""" + modes = [] + for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, []): + google_mode = self.hass_to_google.get(mode) + if google_mode is not None: + modes.append(google_mode) + + return { + 'availableThermostatModes': ','.join(modes), + 'thermostatTemperatureUnit': _google_temp_unit(self.state), + } + + def query_attributes(self): + """Return temperature point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + operation = attrs.get(climate.ATTR_OPERATION_MODE) + if operation is not None and operation in self.hass_to_google: + response['thermostatMode'] = self.hass_to_google[operation] + + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + + current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + response['thermostatTemperatureAmbient'] = \ + round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1) + + current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY) + if current_humidity is not None: + response['thermostatHumidityAmbient'] = current_humidity + + if (operation == climate.STATE_AUTO and + climate.ATTR_TARGET_TEMP_HIGH in attrs and + climate.ATTR_TARGET_TEMP_LOW in attrs): + response['thermostatTemperatureSetpointHigh'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_HIGH], + unit, TEMP_CELSIUS), 1) + response['thermostatTemperatureSetpointLow'] = \ + round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_LOW], + unit, TEMP_CELSIUS), 1) + else: + target_temp = attrs.get(climate.ATTR_TEMPERATURE) + if target_temp is not None: + response['thermostatTemperatureSetpoint'] = round( + temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1) + + return response + + async def execute(self, hass, command, params): + """Execute a temperature point or mode command.""" + # All sent in temperatures are always in Celsius + unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT] + min_temp = self.state.attributes[climate.ATTR_MIN_TEMP] + max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] + + if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: + temp = temp_util.convert(params['thermostatTemperatureSetpoint'], + TEMP_CELSIUS, unit) + + if temp < min_temp or temp > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Temperature should be between {} and {}".format(min_temp, + max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TEMPERATURE: temp + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: + temp_high = temp_util.convert( + params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, + unit) + + if temp_high < min_temp or temp_high > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Upper bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + temp_low = temp_util.convert( + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + + if temp_low < min_temp or temp_low > max_temp: + raise SmartHomeError( + ERR_VALUE_OUT_OF_RANGE, + "Lower bound for temperature range should be between " + "{} and {}".format(min_temp, max_temp)) + + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_TARGET_TEMP_HIGH: temp_high, + climate.ATTR_TARGET_TEMP_LOW: temp_low, + }, blocking=True) + + elif command == COMMAND_THERMOSTAT_SET_MODE: + await hass.services.async_call( + climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, { + ATTR_ENTITY_ID: self.state.entity_id, + climate.ATTR_OPERATION_MODE: + self.google_to_hass[params['thermostatMode']], + }, blocking=True) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 5e4dfdb0bdc..3ece434f3c1 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -257,12 +257,16 @@ def async_setup(hass, config): @asyncio.coroutine def reload_service_handler(service): - """Remove all groups and load new ones from config.""" + """Remove all user-defined groups and load new ones from config.""" + auto = list(filter(lambda e: not e.user_defined, component.entities)) + conf = yield from component.async_prepare_reload() if conf is None: return yield from _async_process_config(hass, conf, component) + yield from component.async_add_entities(auto) + hass.services.async_register( DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=RELOAD_SERVICE_SCHEMA) @@ -407,7 +411,7 @@ class Group(Entity): self.group_off = None self.visible = visible self.control = control - self._user_defined = user_defined + self.user_defined = user_defined self._order = order self._assumed_state = False self._async_unsub_state_changed = None @@ -497,7 +501,7 @@ class Group(Entity): ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order, } - if not self._user_defined: + if not self.user_defined: data[ATTR_AUTO] = True if self.view: data[ATTR_VIEW] = True diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 021c682466e..ad70740536e 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -1,4 +1,4 @@ -"""Support for Apple Homekit. +"""Support for Apple HomeKit. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/homekit/ @@ -11,17 +11,20 @@ import voluptuous as vol from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT, - TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + TEMP_CELSIUS, TEMP_FAHRENHEIT, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.climate import ( + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry TYPES = Registry() _LOGGER = logging.getLogger(__name__) -_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$") +_RE_VALID_PINCODE = r"^(\d{3}-\d{2}-\d{3})$" DOMAIN = 'homekit' -REQUIREMENTS = ['HAP-python==1.1.5'] +REQUIREMENTS = ['HAP-python==1.1.7'] BRIDGE_NAME = 'Home Assistant' CONF_PIN_CODE = 'pincode' @@ -30,11 +33,11 @@ HOMEKIT_FILE = '.homekit.state' def valid_pin(value): - """Validate pincode value.""" - match = _RE_VALID_PINCODE.findall(value.strip()) - if match == []: + """Validate pin code value.""" + match = re.match(_RE_VALID_PINCODE, str(value).strip()) + if not match: raise vol.Invalid("Pin must be in the format: '123-45-678'") - return match[0] + return match.group(0) CONFIG_SCHEMA = vol.Schema({ @@ -47,14 +50,14 @@ CONFIG_SCHEMA = vol.Schema({ @asyncio.coroutine def async_setup(hass, config): - """Setup the homekit component.""" - _LOGGER.debug("Begin setup homekit") + """Setup the HomeKit component.""" + _LOGGER.debug("Begin setup HomeKit") conf = config[DOMAIN] port = conf.get(CONF_PORT) pin = str.encode(conf.get(CONF_PIN_CODE)) - homekit = Homekit(hass, port) + homekit = HomeKit(hass, port) homekit.setup_bridge(pin) hass.bus.async_listen_once( @@ -63,18 +66,18 @@ def async_setup(hass, config): def import_types(): - """Import all types from files in the homekit dir.""" + """Import all types from files in the HomeKit directory.""" _LOGGER.debug("Import type files.") # pylint: disable=unused-variable - from .covers import Window # noqa F401 - # pylint: disable=unused-variable - from .sensors import TemperatureSensor # noqa F401 + from . import ( # noqa F401 + covers, security_systems, sensors, switches, thermostats) def get_accessory(hass, state): """Take state and return an accessory object if supported.""" if state.domain == 'sensor': - if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS: + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT: _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'TemperatureSensor') return TYPES['TemperatureSensor'](hass, state.entity_id, @@ -87,14 +90,35 @@ def get_accessory(hass, state): state.entity_id, 'Window') return TYPES['Window'](hass, state.entity_id, state.name) + elif state.domain == 'alarm_control_panel': + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, + 'SecuritySystem') + return TYPES['SecuritySystem'](hass, state.entity_id, state.name) + + elif state.domain == 'climate': + support_auto = False + features = state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Check if climate device supports auto mode + if (features & SUPPORT_TARGET_TEMPERATURE_HIGH) \ + and (features & SUPPORT_TARGET_TEMPERATURE_LOW): + support_auto = True + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Thermostat') + return TYPES['Thermostat'](hass, state.entity_id, + state.name, support_auto) + + elif state.domain == 'switch' or state.domain == 'remote' \ + or state.domain == 'input_boolean': + _LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Switch') + return TYPES['Switch'](hass, state.entity_id, state.name) + return None -class Homekit(): - """Class to handle all actions between homekit and Home Assistant.""" +class HomeKit(): + """Class to handle all actions between HomeKit and Home Assistant.""" def __init__(self, hass, port): - """Initialize a homekit object.""" + """Initialize a HomeKit object.""" self._hass = hass self._port = port self.bridge = None @@ -103,8 +127,7 @@ class Homekit(): def setup_bridge(self, pin): """Setup the bridge component to track all accessories.""" from .accessories import HomeBridge - self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin) - self.bridge.set_accessory_info('homekit.bridge') + self.bridge = HomeBridge(BRIDGE_NAME, 'homekit.bridge', pin) def start_driver(self, event): """Start the accessory driver.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index e1a25a2c976..689bcb3377c 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,55 +1,69 @@ """Extend the basic Accessory and Bridge functions.""" +import logging + from pyhap.accessory import Accessory, Bridge, Category from .const import ( - SERVICES_ACCESSORY_INFO, MANUFACTURER, + SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, MANUFACTURER, CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) +_LOGGER = logging.getLogger(__name__) + + +def set_accessory_info(acc, model, manufacturer=MANUFACTURER, + serial_number='0000'): + """Set the default accessory information.""" + service = acc.get_service(SERV_ACCESSORY_INFO) + service.get_characteristic(CHAR_MODEL).set_value(model) + service.get_characteristic(CHAR_MANUFACTURER).set_value(manufacturer) + service.get_characteristic(CHAR_SERIAL_NUMBER).set_value(serial_number) + + +def add_preload_service(acc, service, chars=None, opt_chars=None): + """Define and return a service to be available for the accessory.""" + from pyhap.loader import get_serv_loader, get_char_loader + service = get_serv_loader().get(service) + if chars: + chars = chars if isinstance(chars, list) else [chars] + for char_name in chars: + char = get_char_loader().get(char_name) + service.add_characteristic(char) + if opt_chars: + opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] + for opt_char_name in opt_chars: + opt_char = get_char_loader().get(opt_char_name) + service.add_opt_characteristic(opt_char) + acc.add_service(service) + return service + + +def override_properties(char, new_properties): + """Override characteristic property values.""" + char.properties.update(new_properties) + + class HomeAccessory(Accessory): """Class to extend the Accessory class.""" - ALL_CATEGORIES = Category - - def __init__(self, display_name): + def __init__(self, display_name, model, category='OTHER', **kwargs): """Initialize a Accessory object.""" - super().__init__(display_name) + super().__init__(display_name, **kwargs) + set_accessory_info(self, model) + self.category = getattr(Category, category, Category.OTHER) - def set_category(self, category): - """Set the category of the accessory.""" - self.category = category - - def add_preload_service(self, service): - """Define the services to be available for the accessory.""" - from pyhap.loader import get_serv_loader - self.add_service(get_serv_loader().get(service)) - - def set_accessory_info(self, model, manufacturer=MANUFACTURER, - serial_number='0000'): - """Set the default accessory information.""" - service_info = self.get_service(SERVICES_ACCESSORY_INFO) - service_info.get_characteristic(CHAR_MODEL) \ - .set_value(model) - service_info.get_characteristic(CHAR_MANUFACTURER) \ - .set_value(manufacturer) - service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ - .set_value(serial_number) + def _set_services(self): + add_preload_service(self, SERV_ACCESSORY_INFO) class HomeBridge(Bridge): """Class to extend the Bridge class.""" - def __init__(self, display_name, pincode): + def __init__(self, display_name, model, pincode, **kwargs): """Initialize a Bridge object.""" - super().__init__(display_name, pincode=pincode) + super().__init__(display_name, pincode=pincode, **kwargs) + set_accessory_info(self, model) - def set_accessory_info(self, model, manufacturer=MANUFACTURER, - serial_number='0000'): - """Set the default accessory information.""" - service_info = self.get_service(SERVICES_ACCESSORY_INFO) - service_info.get_characteristic(CHAR_MODEL) \ - .set_value(model) - service_info.get_characteristic(CHAR_MANUFACTURER) \ - .set_value(manufacturer) - service_info.get_characteristic(CHAR_SERIAL_NUMBER) \ - .set_value(serial_number) + def _set_services(self): + add_preload_service(self, SERV_ACCESSORY_INFO) + add_preload_service(self, SERV_BRIDGING_STATE) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 6c58b7fe45f..35bd25eabd3 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,18 +1,36 @@ -"""Constants used be the homekit component.""" +"""Constants used be the HomeKit component.""" MANUFACTURER = 'HomeAssistant' -# Service: AccessoryInfomation -SERVICES_ACCESSORY_INFO = 'AccessoryInformation' -CHAR_MODEL = 'Model' -CHAR_MANUFACTURER = 'Manufacturer' -CHAR_SERIAL_NUMBER = 'SerialNumber' +# Services +SERV_ACCESSORY_INFO = 'AccessoryInformation' +SERV_BRIDGING_STATE = 'BridgingState' +SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SWITCH = 'Switch' +SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' +SERV_THERMOSTAT = 'Thermostat' +SERV_WINDOW_COVERING = 'WindowCovering' -# Service: TemperatureSensor -SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor' -CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' - -# Service: WindowCovering -SERVICES_WINDOW_COVERING = 'WindowCovering' +# Characteristics +CHAR_ACC_IDENTIFIER = 'AccessoryIdentifier' +CHAR_CATEGORY = 'Category' +CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' +CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' CHAR_CURRENT_POSITION = 'CurrentPosition' -CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' +CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' +CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' +CHAR_LINK_QUALITY = 'LinkQuality' +CHAR_MANUFACTURER = 'Manufacturer' +CHAR_MODEL = 'Model' +CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' +CHAR_REACHABLE = 'Reachable' +CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' +CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' +CHAR_TARGET_TEMPERATURE = 'TargetTemperature' +CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' + +# Properties +PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} diff --git a/homeassistant/components/homekit/covers.py b/homeassistant/components/homekit/covers.py index 1068b1e0e3f..47713f6c630 100644 --- a/homeassistant/components/homekit/covers.py +++ b/homeassistant/components/homekit/covers.py @@ -5,9 +5,9 @@ from homeassistant.components.cover import ATTR_CURRENT_POSITION from homeassistant.helpers.event import async_track_state_change from . import TYPES -from .accessories import HomeAccessory +from .accessories import HomeAccessory, add_preload_service from .const import ( - SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION, + SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE) @@ -23,10 +23,7 @@ class Window(HomeAccessory): def __init__(self, hass, entity_id, display_name): """Initialize a Window accessory object.""" - super().__init__(display_name) - self.set_category(self.ALL_CATEGORIES.WINDOW) - self.set_accessory_info(entity_id) - self.add_preload_service(SERVICES_WINDOW_COVERING) + super().__init__(display_name, entity_id, 'WINDOW') self._hass = hass self._entity_id = entity_id @@ -34,13 +31,16 @@ class Window(HomeAccessory): self.current_position = None self.homekit_target = None - self.service_cover = self.get_service(SERVICES_WINDOW_COVERING) - self.char_current_position = self.service_cover. \ + self.serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) + self.char_current_position = self.serv_cover. \ get_characteristic(CHAR_CURRENT_POSITION) - self.char_target_position = self.service_cover. \ + self.char_target_position = self.serv_cover. \ get_characteristic(CHAR_TARGET_POSITION) - self.char_position_state = self.service_cover. \ + self.char_position_state = self.serv_cover. \ get_characteristic(CHAR_POSITION_STATE) + self.char_current_position.value = 0 + self.char_target_position.value = 0 + self.char_position_state.value = 0 self.char_target_position.setter_callback = self.move_cover @@ -53,7 +53,7 @@ class Window(HomeAccessory): self._hass, self._entity_id, self.update_cover_position) def move_cover(self, value): - """Move cover to value if call came from homekit.""" + """Move cover to value if call came from HomeKit.""" if value != self.current_position: _LOGGER.debug("%s: Set position to %d", self._entity_id, value) self.homekit_target = value diff --git a/homeassistant/components/homekit/security_systems.py b/homeassistant/components/homekit/security_systems.py new file mode 100644 index 00000000000..1b8f0a6820b --- /dev/null +++ b/homeassistant/components/homekit/security_systems.py @@ -0,0 +1,92 @@ +"""Class to hold all alarm control panel accessories.""" +import logging + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, + ATTR_ENTITY_ID, ATTR_CODE) +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import ( + SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, + CHAR_TARGET_SECURITY_STATE) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_ALARM_DISARMED: 3, STATE_ALARM_ARMED_HOME: 0, + STATE_ALARM_ARMED_AWAY: 1, STATE_ALARM_ARMED_NIGHT: 2} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_ALARM_DISARMED: 'alarm_disarm', + STATE_ALARM_ARMED_HOME: 'alarm_arm_home', + STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', + STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night'} + + +@TYPES.register('SecuritySystem') +class SecuritySystem(HomeAccessory): + """Generate an SecuritySystem accessory for an alarm control panel.""" + + def __init__(self, hass, entity_id, display_name, alarm_code=None): + """Initialize a SecuritySystem accessory object.""" + super().__init__(display_name, entity_id, 'ALARM_SYSTEM') + + self._hass = hass + self._entity_id = entity_id + self._alarm_code = alarm_code + + self.flag_target_state = False + + self.service_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) + self.char_current_state = self.service_alarm. \ + get_characteristic(CHAR_CURRENT_SECURITY_STATE) + self.char_current_state.value = 3 + self.char_target_state = self.service_alarm. \ + get_characteristic(CHAR_TARGET_SECURITY_STATE) + self.char_target_state.value = 3 + + self.char_target_state.setter_callback = self.set_security_state + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_security_state(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_security_state) + + def set_security_state(self, value): + """Move security state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set security state to %d", + self._entity_id, value) + self.flag_target_state = True + hass_value = HOMEKIT_TO_HASS[value] + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self._entity_id} + if self._alarm_code is not None: + params[ATTR_CODE] = self._alarm_code + self._hass.services.call('alarm_control_panel', service, params) + + def update_security_state(self, entity_id=None, + old_state=None, new_state=None): + """Update security state after state changed.""" + if new_state is None: + return + + hass_state = new_state.state + if hass_state not in HASS_TO_HOMEKIT: + return + current_security_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_security_state) + _LOGGER.debug("%s: Updated current state to %s (%d)", + self._entity_id, hass_state, + current_security_state) + + if not self.flag_target_state: + self.char_target_state.set_value(current_security_state, + should_callback=False) + elif self.char_target_state.get_value() \ + == self.char_current_state.get_value(): + self.flag_target_state = False diff --git a/homeassistant/components/homekit/sensors.py b/homeassistant/components/homekit/sensors.py index db9ba2d628a..40f97ae3ef7 100644 --- a/homeassistant/components/homekit/sensors.py +++ b/homeassistant/components/homekit/sensors.py @@ -1,38 +1,54 @@ """Class to hold all sensor accessories.""" import logging -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS) from homeassistant.helpers.event import async_track_state_change from . import TYPES -from .accessories import HomeAccessory +from .accessories import ( + HomeAccessory, add_preload_service, override_properties) from .const import ( - SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE) + SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS) _LOGGER = logging.getLogger(__name__) +def calc_temperature(state, unit=TEMP_CELSIUS): + """Calculate temperature from state and unit. + + Always return temperature as Celsius value. + Conversion is handled on the device. + """ + try: + value = float(state) + except ValueError: + return None + + return round((value - 32) / 1.8, 2) if unit == TEMP_FAHRENHEIT else value + + @TYPES.register('TemperatureSensor') class TemperatureSensor(HomeAccessory): """Generate a TemperatureSensor accessory for a temperature sensor. - Sensor entity must return either temperature in °C or STATE_UNKNOWN. + Sensor entity must return temperature in °C, °F. """ def __init__(self, hass, entity_id, display_name): """Initialize a TemperatureSensor accessory object.""" - super().__init__(display_name) - self.set_category(self.ALL_CATEGORIES.SENSOR) - self.set_accessory_info(entity_id) - self.add_preload_service(SERVICES_TEMPERATURE_SENSOR) + super().__init__(display_name, entity_id, 'SENSOR') self._hass = hass self._entity_id = entity_id - self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR) - self.char_temp = self.service_temp. \ + self.serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR) + self.char_temp = self.serv_temp. \ get_characteristic(CHAR_CURRENT_TEMPERATURE) + override_properties(self.char_temp, PROP_CELSIUS) + self.char_temp.value = 0 + self.unit = None def run(self): """Method called be object after driver is started.""" @@ -48,6 +64,9 @@ class TemperatureSensor(HomeAccessory): if new_state is None: return - temperature = new_state.state - if temperature != STATE_UNKNOWN: - self.char_temp.set_value(float(temperature)) + unit = new_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + temperature = calc_temperature(new_state.state, unit) + if temperature is not None: + self.char_temp.set_value(temperature) + _LOGGER.debug("%s: Current temperature set to %d°C", + self._entity_id, temperature) diff --git a/homeassistant/components/homekit/switches.py b/homeassistant/components/homekit/switches.py new file mode 100644 index 00000000000..876b3406d28 --- /dev/null +++ b/homeassistant/components/homekit/switches.py @@ -0,0 +1,62 @@ +"""Class to hold all switch accessories.""" +import logging + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import split_entity_id +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import SERV_SWITCH, CHAR_ON + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Switch') +class Switch(HomeAccessory): + """Generate a Switch accessory.""" + + def __init__(self, hass, entity_id, display_name): + """Initialize a Switch accessory object to represent a remote.""" + super().__init__(display_name, entity_id, 'SWITCH') + + self._hass = hass + self._entity_id = entity_id + self._domain = split_entity_id(entity_id)[0] + + self.flag_target_state = False + + self.service_switch = add_preload_service(self, SERV_SWITCH) + self.char_on = self.service_switch.get_characteristic(CHAR_ON) + self.char_on.value = False + self.char_on.setter_callback = self.set_state + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_state(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_state) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set switch state to %s", + self._entity_id, value) + self.flag_target_state = True + service = 'turn_on' if value else 'turn_off' + self._hass.services.call(self._domain, service, + {ATTR_ENTITY_ID: self._entity_id}) + + def update_state(self, entity_id=None, old_state=None, new_state=None): + """Update switch state after state changed.""" + if new_state is None: + return + + current_state = (new_state.state == 'on') + if not self.flag_target_state: + _LOGGER.debug("%s: Set current state to %s", + self._entity_id, current_state) + self.char_on.set_value(current_state, should_callback=False) + else: + self.flag_target_state = False diff --git a/homeassistant/components/homekit/thermostats.py b/homeassistant/components/homekit/thermostats.py new file mode 100644 index 00000000000..766a7e3585d --- /dev/null +++ b/homeassistant/components/homekit/thermostats.py @@ -0,0 +1,245 @@ +"""Class to hold all thermostat accessories.""" +import logging + +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, + STATE_HEAT, STATE_COOL, STATE_AUTO) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.event import async_track_state_change + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service +from .const import ( + SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, + CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, + CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, + CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +STATE_OFF = 'off' +UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1} +UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()} +HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1, + STATE_COOL: 2, STATE_AUTO: 3} +HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()} + + +@TYPES.register('Thermostat') +class Thermostat(HomeAccessory): + """Generate a Thermostat accessory for a climate.""" + + def __init__(self, hass, entity_id, display_name, support_auto=False): + """Initialize a Thermostat accessory object.""" + super().__init__(display_name, entity_id, 'THERMOSTAT') + + self._hass = hass + self._entity_id = entity_id + self._call_timer = None + + self.heat_cool_flag_target_state = False + self.temperature_flag_target_state = False + self.coolingthresh_flag_target_state = False + self.heatingthresh_flag_target_state = False + + extra_chars = None + # Add additional characteristics if auto mode is supported + if support_auto: + extra_chars = [CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE] + + # Preload the thermostat service + self.service_thermostat = add_preload_service(self, SERV_THERMOSTAT, + extra_chars) + + # Current and target mode characteristics + self.char_current_heat_cool = self.service_thermostat. \ + get_characteristic(CHAR_CURRENT_HEATING_COOLING) + self.char_current_heat_cool.value = 0 + self.char_target_heat_cool = self.service_thermostat. \ + get_characteristic(CHAR_TARGET_HEATING_COOLING) + self.char_target_heat_cool.value = 0 + self.char_target_heat_cool.setter_callback = self.set_heat_cool + + # Current and target temperature characteristics + self.char_current_temp = self.service_thermostat. \ + get_characteristic(CHAR_CURRENT_TEMPERATURE) + self.char_current_temp.value = 21.0 + self.char_target_temp = self.service_thermostat. \ + get_characteristic(CHAR_TARGET_TEMPERATURE) + self.char_target_temp.value = 21.0 + self.char_target_temp.setter_callback = self.set_target_temperature + + # Display units characteristic + self.char_display_units = self.service_thermostat. \ + get_characteristic(CHAR_TEMP_DISPLAY_UNITS) + self.char_display_units.value = 0 + + # If the device supports it: high and low temperature characteristics + if support_auto: + self.char_cooling_thresh_temp = self.service_thermostat. \ + get_characteristic(CHAR_COOLING_THRESHOLD_TEMPERATURE) + self.char_cooling_thresh_temp.value = 23.0 + self.char_cooling_thresh_temp.setter_callback = \ + self.set_cooling_threshold + + self.char_heating_thresh_temp = self.service_thermostat. \ + get_characteristic(CHAR_HEATING_THRESHOLD_TEMPERATURE) + self.char_heating_thresh_temp.value = 19.0 + self.char_heating_thresh_temp.setter_callback = \ + self.set_heating_threshold + else: + self.char_cooling_thresh_temp = None + self.char_heating_thresh_temp = None + + def run(self): + """Method called be object after driver is started.""" + state = self._hass.states.get(self._entity_id) + self.update_thermostat(new_state=state) + + async_track_state_change(self._hass, self._entity_id, + self.update_thermostat) + + def set_heat_cool(self, value): + """Move operation mode to value if call came from HomeKit.""" + if value in HC_HOMEKIT_TO_HASS: + _LOGGER.debug("%s: Set heat-cool to %d", self._entity_id, value) + self.heat_cool_flag_target_state = True + hass_value = HC_HOMEKIT_TO_HASS[value] + self._hass.services.call('climate', 'set_operation_mode', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_OPERATION_MODE: hass_value}) + + def set_cooling_threshold(self, value): + """Set cooling threshold temp to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set cooling threshold temperature to %.2f", + self._entity_id, value) + self.coolingthresh_flag_target_state = True + low = self.char_heating_thresh_temp.get_value() + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TARGET_TEMP_HIGH: value, + ATTR_TARGET_TEMP_LOW: low}) + + def set_heating_threshold(self, value): + """Set heating threshold temp to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set heating threshold temperature to %.2f", + self._entity_id, value) + self.heatingthresh_flag_target_state = True + # Home assistant always wants to set low and high at the same time + high = self.char_cooling_thresh_temp.get_value() + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TARGET_TEMP_LOW: value, + ATTR_TARGET_TEMP_HIGH: high}) + + def set_target_temperature(self, value): + """Set target temperature to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set target temperature to %.2f", + self._entity_id, value) + self.temperature_flag_target_state = True + self._hass.services.call( + 'climate', 'set_temperature', + {ATTR_ENTITY_ID: self._entity_id, + ATTR_TEMPERATURE: value}) + + def update_thermostat(self, entity_id=None, + old_state=None, new_state=None): + """Update security state after state changed.""" + if new_state is None: + return + + # Update current temperature + current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp is not None: + self.char_current_temp.set_value(current_temp) + + # Update target temperature + target_temp = new_state.attributes.get(ATTR_TEMPERATURE) + if target_temp is not None: + if not self.temperature_flag_target_state: + self.char_target_temp.set_value(target_temp, + should_callback=False) + else: + self.temperature_flag_target_state = False + + # Update cooling threshold temperature if characteristic exists + if self.char_cooling_thresh_temp is not None: + cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH) + if cooling_thresh is not None: + if not self.coolingthresh_flag_target_state: + self.char_cooling_thresh_temp.set_value( + cooling_thresh, should_callback=False) + else: + self.coolingthresh_flag_target_state = False + + # Update heating threshold temperature if characteristic exists + if self.char_heating_thresh_temp is not None: + heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW) + if heating_thresh is not None: + if not self.heatingthresh_flag_target_state: + self.char_heating_thresh_temp.set_value( + heating_thresh, should_callback=False) + else: + self.heatingthresh_flag_target_state = False + + # Update display units + display_units = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if display_units is not None \ + and display_units in UNIT_HASS_TO_HOMEKIT: + self.char_display_units.set_value( + UNIT_HASS_TO_HOMEKIT[display_units]) + + # Update target operation mode + operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE) + if operation_mode is not None \ + and operation_mode in HC_HASS_TO_HOMEKIT: + if not self.heat_cool_flag_target_state: + self.char_target_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[operation_mode], should_callback=False) + else: + self.heat_cool_flag_target_state = False + + # Set current operation mode based on temperatures and target mode + if operation_mode == STATE_HEAT: + if current_temp < target_temp: + current_operation_mode = STATE_HEAT + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_COOL: + if current_temp > target_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + elif operation_mode == STATE_AUTO: + # Check if auto is supported + if self.char_cooling_thresh_temp is not None: + lower_temp = self.char_heating_thresh_temp.get_value() + upper_temp = self.char_cooling_thresh_temp.get_value() + if current_temp < lower_temp: + current_operation_mode = STATE_HEAT + elif current_temp > upper_temp: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + # Check if heating or cooling are supported + heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST] + cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST] + if current_temp < target_temp and heat: + current_operation_mode = STATE_HEAT + elif current_temp > target_temp and cool: + current_operation_mode = STATE_COOL + else: + current_operation_mode = STATE_OFF + else: + current_operation_mode = STATE_OFF + + self.char_current_heat_cool.set_value( + HC_HASS_TO_HOMEKIT[current_operation_mode]) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 450d802e408..4d313b5132e 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -4,21 +4,18 @@ This module provides WSGI application to serve the Home Assistant API. For more details about this component, please refer to the documentation at https://home-assistant.io/components/http/ """ -import asyncio + from ipaddress import ip_network -import json import logging import os import ssl from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently +from aiohttp.web_exceptions import HTTPMovedPermanently import voluptuous as vol from homeassistant.const import ( - SERVER_PORT, CONTENT_TYPE_JSON, - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,) -from homeassistant.core import is_callback + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) import homeassistant.helpers.config_validation as cv import homeassistant.remote as rem import homeassistant.util as hass_util @@ -28,10 +25,13 @@ from .auth import setup_auth from .ban import setup_bans from .cors import setup_cors from .real_ip import setup_real_ip -from .const import KEY_AUTHENTICATED, KEY_REAL_IP from .static import ( CachingFileResponse, CachingStaticResource, staticresource_middleware) +# Import as alias +from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa +from .view import HomeAssistantView # noqa + REQUIREMENTS = ['aiohttp_cors==0.6.0'] DOMAIN = 'http' @@ -98,8 +98,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN) @@ -135,16 +134,14 @@ def async_setup(hass, config): is_ban_enabled=is_ban_enabled ) - @asyncio.coroutine - def stop_server(event): + async def stop_server(event): """Stop the server.""" - yield from server.stop() + await server.stop() - @asyncio.coroutine - def start_server(event): + async def start_server(event): """Start the server.""" hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server) - yield from server.start() + await server.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server) @@ -252,13 +249,11 @@ class HomeAssistantHTTP(object): return if cache_headers: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return CachingFileResponse(path) else: - @asyncio.coroutine - def serve_file(request): + async def serve_file(request): """Serve file from disk.""" return web.FileResponse(path) @@ -276,10 +271,13 @@ class HomeAssistantHTTP(object): self.app.router.add_route('GET', url_pattern, serve_file) - @asyncio.coroutine - def start(self): + async def start(self): """Start the WSGI server.""" - yield from self.app.startup() + # We misunderstood the startup signal. You're not allowed to change + # anything during startup. Temp workaround. + # pylint: disable=protected-access + self.app._on_startup.freeze() + await self.app.startup() if self.ssl_certificate: try: @@ -298,133 +296,24 @@ class HomeAssistantHTTP(object): # Aiohttp freezes apps after start so that no changes can be made. # However in Home Assistant components can be discovered after boot. # This will now raise a RunTimeError. - # To work around this we now fake that we are frozen. - # A more appropriate fix would be to create a new app and - # re-register all redirects, views, static paths. - self.app._frozen = True # pylint: disable=protected-access + # To work around this we now prevent the router from getting frozen + self.app._router.freeze = lambda: None self._handler = self.app.make_handler(loop=self.hass.loop) try: - self.server = yield from self.hass.loop.create_server( + self.server = await self.hass.loop.create_server( self._handler, self.server_host, self.server_port, ssl=context) except OSError as error: _LOGGER.error("Failed to create HTTP server at port %d: %s", self.server_port, error) - # pylint: disable=protected-access - self.app._middlewares = tuple(self.app._prepare_middleware()) - self.app._frozen = False - - @asyncio.coroutine - def stop(self): + async def stop(self): """Stop the WSGI server.""" if self.server: self.server.close() - yield from self.server.wait_closed() - yield from self.app.shutdown() + await self.server.wait_closed() + await self.app.shutdown() if self._handler: - yield from self._handler.shutdown(10) - yield from self.app.cleanup() - - -class HomeAssistantView(object): - """Base view for all views.""" - - url = None - extra_urls = [] - requires_auth = True # Views inheriting from this class can override this - - # pylint: disable=no-self-use - def json(self, result, status_code=200, headers=None): - """Return a JSON response.""" - msg = json.dumps( - result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') - response = web.Response( - body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, - headers=headers) - response.enable_compression() - return response - - def json_message(self, message, status_code=200, message_code=None, - headers=None): - """Return a JSON message response.""" - data = {'message': message} - if message_code is not None: - data['code'] = message_code - return self.json(data, status_code, headers=headers) - - @asyncio.coroutine - # pylint: disable=no-self-use - def file(self, request, fil): - """Return a file.""" - assert isinstance(fil, str), 'only string paths allowed' - return web.FileResponse(fil) - - def register(self, router): - """Register the view with a router.""" - assert self.url is not None, 'No url set for view' - urls = [self.url] + self.extra_urls - - for method in ('get', 'post', 'delete', 'put'): - handler = getattr(self, method, None) - - if not handler: - continue - - handler = request_handler_factory(self, handler) - - for url in urls: - router.add_route(method, url, handler) - - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) - - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) - - -def request_handler_factory(view, handler): - """Wrap the handler classes.""" - assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ - "Handler should be a coroutine or a callback." - - @asyncio.coroutine - def handle(request): - """Handle incoming request.""" - if not request.app['hass'].is_running: - return web.Response(status=503) - - authenticated = request.get(KEY_AUTHENTICATED, False) - - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() - - _LOGGER.info('Serving %s to %s (auth: %s)', - request.path, request.get(KEY_REAL_IP), authenticated) - - result = handler(request, **request.match_info) - - if asyncio.iscoroutine(result): - result = yield from result - - if isinstance(result, web.StreamResponse): - # The method handler returned a ready-made Response, how nice of it - return result - - status_code = 200 - - if isinstance(result, tuple): - result, status_code = result - - if isinstance(result, str): - result = result.encode('utf-8') - elif result is None: - result = b'' - elif not isinstance(result, bytes): - assert False, ('Result should be None, string, bytes or Response. ' - 'Got: {}').format(result) - - return web.Response(body=result, status=status_code) - - return handle + await self._handler.shutdown(10) + await self.app.cleanup() diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 3128489437a..65c70c37bd2 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,5 +1,5 @@ """Authentication for HTTP component.""" -import asyncio + import base64 import hmac import logging @@ -20,13 +20,12 @@ _LOGGER = logging.getLogger(__name__) def setup_auth(app, trusted_networks, api_password): """Create auth middleware for the app.""" @middleware - @asyncio.coroutine - def auth_middleware(request, handler): + async def auth_middleware(request, handler): """Authenticate as middleware.""" # If no password set, just always set authenticated=True if api_password is None: request[KEY_AUTHENTICATED] = True - return (yield from handler(request)) + return await handler(request) # Check authentication authenticated = False @@ -50,10 +49,9 @@ def setup_auth(app, trusted_networks, api_password): authenticated = True request[KEY_AUTHENTICATED] = authenticated - return (yield from handler(request)) + return await handler(request) - @asyncio.coroutine - def auth_startup(app): + async def auth_startup(app): """Initialize auth middleware when app starts up.""" app.middlewares.append(auth_middleware) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 4c797b05b19..fe8b7db84d1 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -1,5 +1,5 @@ """Ban logic for HTTP component.""" -import asyncio + from collections import defaultdict from datetime import datetime from ipaddress import ip_address @@ -38,11 +38,10 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({ @callback def setup_bans(hass, app, login_threshold): """Create IP Ban middleware for the app.""" - @asyncio.coroutine - def ban_startup(app): + async def ban_startup(app): """Initialize bans when app starts up.""" app.middlewares.append(ban_middleware) - app[KEY_BANNED_IPS] = yield from hass.async_add_job( + app[KEY_BANNED_IPS] = await hass.async_add_job( load_ip_bans_config, hass.config.path(IP_BANS_FILE)) app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int) app[KEY_LOGIN_THRESHOLD] = login_threshold @@ -51,12 +50,11 @@ def setup_bans(hass, app, login_threshold): @middleware -@asyncio.coroutine -def ban_middleware(request, handler): +async def ban_middleware(request, handler): """IP Ban middleware.""" if KEY_BANNED_IPS not in request.app: _LOGGER.error('IP Ban middleware loaded but banned IPs not loaded') - return (yield from handler(request)) + return await handler(request) # Verify if IP is not banned ip_address_ = request[KEY_REAL_IP] @@ -67,14 +65,13 @@ def ban_middleware(request, handler): raise HTTPForbidden() try: - return (yield from handler(request)) + return await handler(request) except HTTPUnauthorized: - yield from process_wrong_login(request) + await process_wrong_login(request) raise -@asyncio.coroutine -def process_wrong_login(request): +async def process_wrong_login(request): """Process a wrong login attempt.""" remote_addr = request[KEY_REAL_IP] @@ -98,7 +95,7 @@ def process_wrong_login(request): request.app[KEY_BANNED_IPS].append(new_ban) hass = request.app['hass'] - yield from hass.async_add_job( + await hass.async_add_job( update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban) _LOGGER.warning( diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 2eb92732d1e..0a37f22867e 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,5 +1,5 @@ """Provide cors support for the HTTP component.""" -import asyncio + from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE @@ -27,8 +27,7 @@ def setup_cors(app, origins): ) for host in origins }) - @asyncio.coroutine - def cors_startup(app): + async def cors_startup(app): """Initialize cors when app starts up.""" cors_added = set() diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 528c0a598e3..8fc7cd8e658 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -1,5 +1,5 @@ """Decorator for view methods to help with data validation.""" -import asyncio + from functools import wraps import logging @@ -24,16 +24,15 @@ class RequestDataValidator: def __call__(self, method): """Decorate a function.""" - @asyncio.coroutine @wraps(method) - def wrapper(view, request, *args, **kwargs): + async def wrapper(view, request, *args, **kwargs): """Wrap a request handler with data validation.""" data = None try: - data = yield from request.json() + data = await request.json() except ValueError: if not self._allow_empty or \ - (yield from request.content.read()) != b'': + (await request.content.read()) != b'': _LOGGER.error('Invalid JSON received.') return view.json_message('Invalid JSON.', 400) data = {} @@ -45,7 +44,7 @@ class RequestDataValidator: return view.json_message( 'Message format incorrect: {}'.format(err), 400) - result = yield from method(view, request, *args, **kwargs) + result = await method(view, request, *args, **kwargs) return result return wrapper diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index 1e50f33f69e..c394016a683 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -1,5 +1,5 @@ """Middleware to fetch real IP.""" -import asyncio + from ipaddress import ip_address from aiohttp.web import middleware @@ -14,8 +14,7 @@ from .const import KEY_REAL_IP def setup_real_ip(app, use_x_forwarded_for): """Create IP Ban middleware for the app.""" @middleware - @asyncio.coroutine - def real_ip_middleware(request, handler): + async def real_ip_middleware(request, handler): """Real IP middleware.""" if (use_x_forwarded_for and X_FORWARDED_FOR in request.headers): @@ -25,10 +24,9 @@ def setup_real_ip(app, use_x_forwarded_for): request[KEY_REAL_IP] = \ ip_address(request.transport.get_extra_info('peername')[0]) - return (yield from handler(request)) + return await handler(request) - @asyncio.coroutine - def app_startup(app): + async def app_startup(app): """Initialize bans when app starts up.""" app.middlewares.append(real_ip_middleware) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index b34df1897f0..3fbaf703d06 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -1,5 +1,5 @@ """Static file handling for HTTP component.""" -import asyncio + import re from aiohttp import hdrs @@ -14,8 +14,7 @@ _FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE) class CachingStaticResource(StaticResource): """Static Resource handler that will add cache headers.""" - @asyncio.coroutine - def _handle(self, request): + async def _handle(self, request): filename = URL(request.match_info['filename']).path try: # PyLint is wrong about resolve not being a member. @@ -32,13 +31,14 @@ class CachingStaticResource(StaticResource): raise HTTPNotFound() from error if filepath.is_dir(): - return (yield from super()._handle(request)) + return await super()._handle(request) elif filepath.is_file(): return CachingFileResponse(filepath, chunk_size=self._chunk_size) else: raise HTTPNotFound +# pylint: disable=too-many-ancestors class CachingFileResponse(FileResponse): """FileSender class that caches output if not in dev mode.""" @@ -48,26 +48,24 @@ class CachingFileResponse(FileResponse): orig_sendfile = self._sendfile - @asyncio.coroutine - def sendfile(request, fobj, count): + async def sendfile(request, fobj, count): """Sendfile that includes a cache header.""" cache_time = 31 * 86400 # = 1 month self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format( cache_time) - yield from orig_sendfile(request, fobj, count) + await orig_sendfile(request, fobj, count) # Overwriting like this because __init__ can change implementation. self._sendfile = sendfile @middleware -@asyncio.coroutine -def staticresource_middleware(request, handler): +async def staticresource_middleware(request, handler): """Middleware to strip out fingerprint from fingerprinted assets.""" path = request.path if not path.startswith('/static/') and not path.startswith('/frontend'): - return (yield from handler(request)) + return await handler(request) fingerprinted = _FINGERPRINT.match(request.match_info['filename']) @@ -75,4 +73,4 @@ def staticresource_middleware(request, handler): request.match_info['filename'] = \ '{}.{}'.format(*fingerprinted.groups()) - return (yield from handler(request)) + return await handler(request) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py new file mode 100644 index 00000000000..299a10e9f5a --- /dev/null +++ b/homeassistant/components/http/view.py @@ -0,0 +1,121 @@ +""" +This module provides WSGI application to serve the Home Assistant API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/http/ +""" +import asyncio +import json +import logging + +from aiohttp import web +from aiohttp.web_exceptions import HTTPUnauthorized + +import homeassistant.remote as rem +from homeassistant.core import is_callback +from homeassistant.const import CONTENT_TYPE_JSON + +from .const import KEY_AUTHENTICATED, KEY_REAL_IP + + +_LOGGER = logging.getLogger(__name__) + + +class HomeAssistantView(object): + """Base view for all views.""" + + url = None + extra_urls = [] + requires_auth = True # Views inheriting from this class can override this + + # pylint: disable=no-self-use + def json(self, result, status_code=200, headers=None): + """Return a JSON response.""" + msg = json.dumps( + result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8') + response = web.Response( + body=msg, content_type=CONTENT_TYPE_JSON, status=status_code, + headers=headers) + response.enable_compression() + return response + + def json_message(self, message, status_code=200, message_code=None, + headers=None): + """Return a JSON message response.""" + data = {'message': message} + if message_code is not None: + data['code'] = message_code + return self.json(data, status_code, headers=headers) + + # pylint: disable=no-self-use + async def file(self, request, fil): + """Return a file.""" + assert isinstance(fil, str), 'only string paths allowed' + return web.FileResponse(fil) + + def register(self, router): + """Register the view with a router.""" + assert self.url is not None, 'No url set for view' + urls = [self.url] + self.extra_urls + + for method in ('get', 'post', 'delete', 'put'): + handler = getattr(self, method, None) + + if not handler: + continue + + handler = request_handler_factory(self, handler) + + for url in urls: + router.add_route(method, url, handler) + + # aiohttp_cors does not work with class based views + # self.app.router.add_route('*', self.url, self, name=self.name) + + # for url in self.extra_urls: + # self.app.router.add_route('*', url, self) + + +def request_handler_factory(view, handler): + """Wrap the handler classes.""" + assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \ + "Handler should be a coroutine or a callback." + + async def handle(request): + """Handle incoming request.""" + if not request.app['hass'].is_running: + return web.Response(status=503) + + authenticated = request.get(KEY_AUTHENTICATED, False) + + if view.requires_auth and not authenticated: + raise HTTPUnauthorized() + + _LOGGER.info('Serving %s to %s (auth: %s)', + request.path, request.get(KEY_REAL_IP), authenticated) + + result = handler(request, **request.match_info) + + if asyncio.iscoroutine(result): + result = await result + + if isinstance(result, web.StreamResponse): + # The method handler returned a ready-made Response, how nice of it + return result + + status_code = 200 + + if isinstance(result, tuple): + result, status_code = result + + if isinstance(result, str): + result = result.encode('utf-8') + elif result is None: + result = b'' + elif not isinstance(result, bytes): + assert False, ('Result should be None, string, bytes or Response. ' + 'Got: {}').format(result) + + return web.Response(body=result, status=status_code) + + return handle diff --git a/homeassistant/components/hue.py b/homeassistant/components/hue.py index 2d64306ca74..d3870f0a3a1 100644 --- a/homeassistant/components/hue.py +++ b/homeassistant/components/hue.py @@ -4,20 +4,24 @@ This component provides basic support for the Philips Hue system. For more details about this component, please refer to the documentation at https://home-assistant.io/components/hue/ """ +import asyncio import json +from functools import partial import logging import os import socket +import async_timeout import requests import voluptuous as vol from homeassistant.components.discovery import SERVICE_HUE from homeassistant.const import CONF_FILENAME, CONF_HOST import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, aiohttp_client +from homeassistant import config_entries -REQUIREMENTS = ['phue==1.0'] +REQUIREMENTS = ['phue==1.0', 'aiohue==0.3.0'] _LOGGER = logging.getLogger(__name__) @@ -133,13 +137,14 @@ def bridge_discovered(hass, service, discovery_info): def setup_bridge(host, hass, filename=None, allow_unreachable=False, - allow_in_emulated_hue=True, allow_hue_groups=True): + allow_in_emulated_hue=True, allow_hue_groups=True, + username=None): """Set up a given Hue bridge.""" # Only register a device once if socket.gethostbyname(host) in hass.data[DOMAIN]: return - bridge = HueBridge(host, hass, filename, allow_unreachable, + bridge = HueBridge(host, hass, filename, username, allow_unreachable, allow_in_emulated_hue, allow_hue_groups) bridge.setup() @@ -164,13 +169,14 @@ def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE): class HueBridge(object): """Manages a single Hue bridge.""" - def __init__(self, host, hass, filename, allow_unreachable=False, + def __init__(self, host, hass, filename, username, allow_unreachable=False, allow_in_emulated_hue=True, allow_hue_groups=True): """Initialize the system.""" self.host = host self.bridge_id = socket.gethostbyname(host) self.hass = hass self.filename = filename + self.username = username self.allow_unreachable = allow_unreachable self.allow_in_emulated_hue = allow_in_emulated_hue self.allow_hue_groups = allow_hue_groups @@ -189,10 +195,14 @@ class HueBridge(object): import phue try: - self.bridge = phue.Bridge( - self.host, - config_file_path=self.hass.config.path(self.filename)) - except (ConnectionRefusedError, OSError): # Wrong host was given + kwargs = {} + if self.username is not None: + kwargs['username'] = self.username + if self.filename is not None: + kwargs['config_file_path'] = \ + self.hass.config.path(self.filename) + self.bridge = phue.Bridge(self.host, **kwargs) + except OSError: # Wrong host was given _LOGGER.error("Error connecting to the Hue bridge at %s", self.host) return @@ -204,6 +214,7 @@ class HueBridge(object): except Exception: # pylint: disable=broad-except _LOGGER.exception("Unknown error connecting with Hue bridge at %s", self.host) + return # If we came here and configuring this host, mark as done if self.config_request_id: @@ -260,3 +271,112 @@ class HueBridge(object): def set_group(self, light_id, command): """Change light settings for a group. See phue for detail.""" return self.bridge.set_group(light_id, command) + + +@config_entries.HANDLERS.register(DOMAIN) +class HueFlowHandler(config_entries.ConfigFlowHandler): + """Handle a Hue config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the Hue flow.""" + self.host = None + + @property + def _websession(self): + """Return a websession. + + Cannot assign in init because hass variable is not set yet. + """ + return aiohttp_client.async_get_clientsession(self.hass) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + from aiohue.discovery import discover_nupnp + + if user_input is not None: + self.host = user_input['host'] + return await self.async_step_link() + + try: + with async_timeout.timeout(5): + bridges = await discover_nupnp(websession=self._websession) + except asyncio.TimeoutError: + return self.async_abort( + reason='Unable to discover Hue bridges.' + ) + + if not bridges: + return self.async_abort( + reason='No Philips Hue bridges discovered.' + ) + + # Find already configured hosts + configured_hosts = set( + entry.data['host'] for entry + in self.hass.config_entries.async_entries(DOMAIN)) + + hosts = [bridge.host for bridge in bridges + if bridge.host not in configured_hosts] + + if not hosts: + return self.async_abort( + reason='All Philips Hue bridges are already configured.' + ) + + elif len(hosts) == 1: + self.host = hosts[0] + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + title='Pick Hue Bridge', + data_schema=vol.Schema({ + vol.Required('host'): vol.In(hosts) + }) + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the Hue bridge.""" + import aiohue + errors = {} + + if user_input is not None: + bridge = aiohue.Bridge(self.host, websession=self._websession) + try: + with async_timeout.timeout(5): + # Create auth token + await bridge.create_user('home-assistant') + # Fetches name and id + await bridge.initialize() + except (asyncio.TimeoutError, aiohue.RequestError, + aiohue.LinkButtonNotPressed): + errors['base'] = 'Failed to register, please try again.' + except aiohue.AiohueException: + errors['base'] = 'Unknown linking error occurred.' + _LOGGER.exception('Uknown Hue linking error occurred') + else: + return self.async_create_entry( + title=bridge.config.name, + data={ + 'host': bridge.host, + 'bridge_id': bridge.config.bridgeid, + 'username': bridge.username, + } + ) + + return self.async_show_form( + step_id='link', + title='Link Hub', + description=CONFIG_INSTRUCTIONS, + errors=errors, + ) + + +async def async_setup_entry(hass, entry): + """Set up a bridge for a config entry.""" + await hass.async_add_job(partial( + setup_bridge, entry.data['host'], hass, + username=entry.data['username'])) + return True diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 04be7dd5ab0..031fa263e5a 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType -REQUIREMENTS = ['ihcsdk==2.1.1'] +REQUIREMENTS = ['ihcsdk==2.2.0'] DOMAIN = 'ihc' IHC_DATA = 'ihc' diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 2c2b8364823..061fd5d7074 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -43,7 +43,7 @@ DEFAULT_TIMEOUT = 10 DEFAULT_CONFIDENCE = 80 SOURCE_SCHEMA = vol.Schema({ - vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_ENTITY_ID): cv.entity_domain('camera'), vol.Optional(CONF_NAME): cv.string, }) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 526b8057ce1..1f7f9f6262f 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -35,7 +35,6 @@ CONF_COMPONENT_CONFIG = 'component_config' CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob' CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain' CONF_RETRY_COUNT = 'max_retries' -CONF_RETRY_QUEUE = 'retry_queue_limit' DEFAULT_DATABASE = 'home_assistant' DEFAULT_VERIFY_SSL = True @@ -43,14 +42,17 @@ DOMAIN = 'influxdb' TIMEOUT = 5 RETRY_DELAY = 20 -QUEUE_BACKLOG_SECONDS = 10 +QUEUE_BACKLOG_SECONDS = 30 + +BATCH_TIMEOUT = 1 +BATCH_BUFFER_SIZE = 100 COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, }) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.deprecated(CONF_RETRY_QUEUE), vol.Schema({ + DOMAIN: vol.All(vol.Schema({ vol.Optional(CONF_HOST): cv.string, vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, @@ -68,7 +70,6 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_SSL): cv.boolean, vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int, - vol.Optional(CONF_RETRY_QUEUE, default=20): cv.positive_int, vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string, vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string, vol.Optional(CONF_TAGS, default={}): @@ -143,18 +144,18 @@ def setup(hass, config): "READ/WRITE", exc) return False - def influx_handle_event(event): - """Send an event to Influx.""" + def event_to_json(event): + """Add an event to the outgoing Influx list.""" state = event.data.get('new_state') if state is None or state.state in ( STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \ state.entity_id in blacklist_e or state.domain in blacklist_d: - return True + return try: if (whitelist_e and state.entity_id not in whitelist_e) or \ (whitelist_d and state.domain not in whitelist_d): - return True + return _include_state = _include_value = False @@ -183,61 +184,59 @@ def setup(hass, config): else: include_uom = False - json_body = [ - { - 'measurement': measurement, - 'tags': { - 'domain': state.domain, - 'entity_id': state.object_id, - }, - 'time': event.time_fired, - 'fields': { - } - } - ] + json = { + 'measurement': measurement, + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired, + 'fields': {} + } if _include_state: - json_body[0]['fields']['state'] = state.state + json['fields']['state'] = state.state if _include_value: - json_body[0]['fields']['value'] = _state_as_value + json['fields']['value'] = _state_as_value for key, value in state.attributes.items(): if key in tags_attributes: - json_body[0]['tags'][key] = value + json['tags'][key] = value elif key != 'unit_of_measurement' or include_uom: # If the key is already in fields - if key in json_body[0]['fields']: + if key in json['fields']: key = key + "_" # Prevent column data errors in influxDB. # For each value we try to cast it as float # But if we can not do it we store the value # as string add "_str" postfix to the field key try: - json_body[0]['fields'][key] = float(value) + json['fields'][key] = float(value) except (ValueError, TypeError): new_key = "{}_str".format(key) new_value = str(value) - json_body[0]['fields'][new_key] = new_value + json['fields'][new_key] = new_value if RE_DIGIT_TAIL.match(new_value): - json_body[0]['fields'][key] = float( + json['fields'][key] = float( RE_DECIMAL.sub('', new_value)) - json_body[0]['tags'].update(tags) + # Infinity is not a valid float in InfluxDB + if (key, float("inf")) in json['fields'].items(): + del json['fields'][key] - try: - influx.write_points(json_body) - return True - except (exceptions.InfluxDBClientError, IOError): - return False + json['tags'].update(tags) + + return json instance = hass.data[DOMAIN] = InfluxThread( - hass, influx_handle_event, max_tries) + hass, influx, event_to_json, max_tries) instance.start() def shutdown(event): """Shut down the thread.""" instance.queue.put(None) instance.join() + influx.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) @@ -247,12 +246,15 @@ def setup(hass, config): class InfluxThread(threading.Thread): """A threaded event handler class.""" - def __init__(self, hass, event_handler, max_tries): + def __init__(self, hass, influx, event_to_json, max_tries): """Initialize the listener.""" threading.Thread.__init__(self, name='InfluxDB') self.queue = queue.Queue() - self.event_handler = event_handler + self.influx = influx + self.event_to_json = event_to_json self.max_tries = max_tries + self.write_errors = 0 + self.shutdown = False hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) def _event_listener(self, event): @@ -260,41 +262,77 @@ class InfluxThread(threading.Thread): item = (time.monotonic(), event) self.queue.put(item) - def run(self): - """Process incoming events.""" + @staticmethod + def batch_timeout(): + """Return number of seconds to wait for more events.""" + return BATCH_TIMEOUT + + def get_events_json(self): + """Return a batch of events formatted for writing.""" queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY - write_error = False - dropped = False + count = 0 + json = [] - while True: - item = self.queue.get() + dropped = 0 - if item is None: + try: + while len(json) < BATCH_BUFFER_SIZE and not self.shutdown: + timeout = None if count == 0 else self.batch_timeout() + item = self.queue.get(timeout=timeout) + count += 1 + + if item is None: + self.shutdown = True + else: + timestamp, event = item + age = time.monotonic() - timestamp + + if age < queue_seconds: + event_json = self.event_to_json(event) + if event_json: + json.append(event_json) + else: + dropped += 1 + + except queue.Empty: + pass + + if dropped: + _LOGGER.warning("Catching up, dropped %d old events", dropped) + + return count, json + + def write_to_influxdb(self, json): + """Write preprocessed events to influxdb, with retry.""" + from influxdb import exceptions + + for retry in range(self.max_tries+1): + try: + self.influx.write_points(json) + + if self.write_errors: + _LOGGER.error("Resumed, lost %d events", self.write_errors) + self.write_errors = 0 + + _LOGGER.debug("Wrote %d events", len(json)) + break + except (exceptions.InfluxDBClientError, IOError): + if retry < self.max_tries: + time.sleep(RETRY_DELAY) + else: + if not self.write_errors: + _LOGGER.exception("Write error") + self.write_errors += len(json) + + def run(self): + """Process incoming events.""" + while not self.shutdown: + count, json = self.get_events_json() + if json: + self.write_to_influxdb(json) + for _ in range(count): self.queue.task_done() - return - - timestamp, event = item - age = time.monotonic() - timestamp - - if age < queue_seconds: - for retry in range(self.max_tries+1): - if self.event_handler(event): - if write_error: - _LOGGER.error("Resumed writing to InfluxDB") - write_error = False - dropped = False - break - elif retry < self.max_tries: - time.sleep(RETRY_DELAY) - elif not write_error: - _LOGGER.error("Error writing to InfluxDB") - write_error = True - elif not dropped: - _LOGGER.warning("Dropping old events to catch up") - dropped = True - - self.queue.task_done() def block_till_done(self): """Block till all events processed.""" diff --git a/homeassistant/components/insteon_plm.py b/homeassistant/components/insteon_plm.py index 4e2e8e02c7a..2381e3db69e 100644 --- a/homeassistant/components/insteon_plm.py +++ b/homeassistant/components/insteon_plm.py @@ -4,117 +4,211 @@ Support for INSTEON PowerLinc Modem. For more details about this component, please refer to the documentation at https://home-assistant.io/components/insteon_plm/ """ -import logging import asyncio - +import collections +import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import ( - CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.const import (CONF_PORT, EVENT_HOMEASSISTANT_STOP, + CONF_PLATFORM) import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.7.5'] +REQUIREMENTS = ['insteonplm==0.8.2'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'insteon_plm' CONF_OVERRIDE = 'device_override' +CONF_ADDRESS = 'address' +CONF_CAT = 'cat' +CONF_SUBCAT = 'subcat' +CONF_FIRMWARE = 'firmware' +CONF_PRODUCT_KEY = 'product_key' + +CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( + cv.deprecated(CONF_PLATFORM), vol.Schema({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Optional(CONF_CAT): cv.byte, + vol.Optional(CONF_SUBCAT): cv.byte, + vol.Optional(CONF_FIRMWARE): cv.byte, + vol.Optional(CONF_PRODUCT_KEY): cv.byte, + vol.Optional(CONF_PLATFORM): cv.string, + })) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): cv.string, - vol.Optional(CONF_OVERRIDE, default=[]): cv.ensure_list_csv, - }) + vol.Optional(CONF_OVERRIDE): vol.All( + cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]) + }) }, extra=vol.ALLOW_EXTRA) -PLM_PLATFORMS = { - 'binary_sensor': ['binary_sensor'], - 'light': ['light'], - 'switch': ['switch'], -} - @asyncio.coroutine def async_setup(hass, config): """Set up the connection to the PLM.""" import insteonplm + ipdb = IPDB() + conf = config[DOMAIN] port = conf.get(CONF_PORT) - overrides = conf.get(CONF_OVERRIDE) + overrides = conf.get(CONF_OVERRIDE, []) @callback def async_plm_new_device(device): """Detect device from transport to be delegated to platform.""" - name = device.get('address') - address = device.get('address_hex') - capabilities = device.get('capabilities', []) + for state_key in device.states: + platform_info = ipdb[device.states[state_key]] + platform = platform_info.platform + if platform is not None: + _LOGGER.info("New INSTEON PLM device: %s (%s) %s", + device.address, + device.states[state_key].name, + platform) - _LOGGER.info("New INSTEON PLM device: %s (%s) %r", - name, address, capabilities) - - loadlist = [] - for platform in PLM_PLATFORMS: - caplist = PLM_PLATFORMS.get(platform) - for key in capabilities: - if key in caplist: - loadlist.append(platform) - - loadlist = sorted(set(loadlist)) - - for loadplatform in loadlist: - hass.async_add_job( - discovery.async_load_platform( - hass, loadplatform, DOMAIN, discovered=[device], - hass_config=config)) + hass.async_add_job( + discovery.async_load_platform( + hass, platform, DOMAIN, + discovered={'address': device.address.hex, + 'state_key': state_key}, + hass_config=config)) _LOGGER.info("Looking for PLM on %s", port) - plm = yield from insteonplm.Connection.create(device=port, loop=hass.loop) + conn = yield from insteonplm.Connection.create( + device=port, + loop=hass.loop, + workdir=hass.config.config_dir) - for device in overrides: + plm = conn.protocol + + for device_override in overrides: # # Override the device default capabilities for a specific address # - if isinstance(device['platform'], list): - plm.protocol.devices.add_override( - device['address'], 'capabilities', device['platform']) - else: - plm.protocol.devices.add_override( - device['address'], 'capabilities', [device['platform']]) + address = device_override.get('address') + for prop in device_override: + if prop in [CONF_CAT, CONF_SUBCAT]: + plm.devices.add_override(address, prop, + device_override[prop]) + elif prop in [CONF_FIRMWARE, CONF_PRODUCT_KEY]: + plm.devices.add_override(address, CONF_PRODUCT_KEY, + device_override[prop]) hass.data['insteon_plm'] = plm - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, plm.close) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) - plm.protocol.devices.add_device_callback(async_plm_new_device, {}) + plm.devices.add_device_callback(async_plm_new_device) return True -def common_attributes(entity): - """Return the device state attributes.""" - attributes = {} - attributekeys = { - 'address': 'INSTEON Address', - 'description': 'Description', - 'model': 'Model', - 'cat': 'Category', - 'subcat': 'Subcategory', - 'firmware': 'Firmware', - 'product_key': 'Product Key' - } +State = collections.namedtuple('Product', 'stateType platform') - hexkeys = ['cat', 'subcat', 'firmware'] - for key in attributekeys: - name = attributekeys[key] - val = entity.get_attr(key) - if val is not None: - if key in hexkeys: - attributes[name] = hex(int(val)) - else: - attributes[name] = val - return attributes +class IPDB(object): + """Embodies the INSTEON Product Database static data and access methods.""" + + def __init__(self): + """Create the INSTEON Product Database (IPDB).""" + from insteonplm.states.onOff import (OnOffSwitch, + OnOffSwitch_OutletTop, + OnOffSwitch_OutletBottom, + OpenClosedRelay) + + from insteonplm.states.dimmable import (DimmableSwitch, + DimmableSwitch_Fan) + + from insteonplm.states.sensor import (VariableSensor, + OnOffSensor, + SmokeCO2Sensor, + IoLincSensor) + + self.states = [State(OnOffSwitch_OutletTop, 'switch'), + State(OnOffSwitch_OutletBottom, 'switch'), + State(OpenClosedRelay, 'switch'), + State(OnOffSwitch, 'switch'), + + State(IoLincSensor, 'binary_sensor'), + State(SmokeCO2Sensor, 'sensor'), + State(OnOffSensor, 'binary_sensor'), + State(VariableSensor, 'sensor'), + + State(DimmableSwitch_Fan, 'fan'), + State(DimmableSwitch, 'light')] + + def __len__(self): + """Return the number of INSTEON state types mapped to HA platforms.""" + return len(self.states) + + def __iter__(self): + """Itterate through the INSTEON state types to HA platforms.""" + for product in self.states: + yield product + + def __getitem__(self, key): + """Return a Home Assistant platform from an INSTEON state type.""" + for state in self.states: + if isinstance(key, state.stateType): + return state + return None + + +class InsteonPLMEntity(Entity): + """INSTEON abstract base entity.""" + + def __init__(self, device, state_key): + """Initialize the INSTEON PLM binary sensor.""" + self._insteon_device_state = device.states[state_key] + self._insteon_device = device + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def address(self): + """Return the address of the node.""" + return self._insteon_device.address.human + + @property + def group(self): + """Return the INSTEON group that the entity responds to.""" + return self._insteon_device_state.group + + @property + def name(self): + """Return the name of the node (used for Entity_ID).""" + name = '' + if self._insteon_device_state.group == 0x01: + name = self._insteon_device.id + else: + name = '{:s}_{:d}'.format(self._insteon_device.id, + self._insteon_device_state.group) + return name + + @property + def device_state_attributes(self): + """Provide attributes for display on device card.""" + attributes = { + 'INSTEON Address': self.address, + 'INSTEON Group': self.group + } + return attributes + + @callback + def async_entity_update(self, deviceid, statename, val): + """Receive notification from transport that new data exists.""" + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Register INSTEON update events.""" + self._insteon_device_state.register_updates( + self.async_entity_update) diff --git a/homeassistant/components/juicenet.py b/homeassistant/components/juicenet.py index 728a4fccf85..55567d45879 100644 --- a/homeassistant/components/juicenet.py +++ b/homeassistant/components/juicenet.py @@ -70,5 +70,5 @@ class JuicenetDevice(Entity): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return "{}-{}".format(self.device.id(), self.type) diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index a90a5246759..f6f41619ca8 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -4,17 +4,20 @@ Connects to KNX platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/knx/ """ -import asyncio + import logging import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.8.3'] +REQUIREMENTS = ['xknx==0.8.4'] DOMAIN = "knx" DATA_KNX = "data_knx" @@ -26,6 +29,9 @@ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" CONF_KNX_STATE_UPDATER = "state_updater" +CONF_KNX_EXPOSE = "expose" +CONF_KNX_EXPOSE_TYPE = "type" +CONF_KNX_EXPOSE_ADDRESS = "address" SERVICE_KNX_SEND = "send" SERVICE_KNX_ATTR_ADDRESS = "address" @@ -45,6 +51,12 @@ ROUTING_SCHEMA = vol.Schema({ vol.Required(CONF_KNX_LOCAL_IP): cv.string, }) +EXPOSE_SCHEMA = vol.Schema({ + vol.Required(CONF_KNX_EXPOSE_TYPE): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string, +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_KNX_CONFIG): cv.string, @@ -56,6 +68,10 @@ CONFIG_SCHEMA = vol.Schema({ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, + vol.Optional(CONF_KNX_EXPOSE): + vol.All( + cv.ensure_list, + [EXPOSE_SCHEMA]), }) }, extra=vol.ALLOW_EXTRA) @@ -66,13 +82,13 @@ SERVICE_KNX_SEND_SCHEMA = vol.Schema({ }) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the KNX component.""" from xknx.exceptions import XKNXException try: hass.data[DATA_KNX] = KNXModule(hass, config) - yield from hass.data[DATA_KNX].start() + hass.data[DATA_KNX].async_create_exposures() + await hass.data[DATA_KNX].start() except XKNXException as ex: _LOGGER.warning("Can't connect to KNX interface: %s", ex) @@ -88,6 +104,7 @@ def async_setup(hass, config): ('light', 'Light'), ('sensor', 'Sensor'), ('binary_sensor', 'BinarySensor'), + ('scene', 'Scene'), ('notify', 'Notification')): found_devices = _get_devices(hass, discovery_type) hass.async_add_job( @@ -122,26 +139,25 @@ class KNXModule(object): self.connected = False self.init_xknx() self.register_callbacks() + self.exposures = [] def init_xknx(self): """Initialize of KNX object.""" from xknx import XKNX self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop) - @asyncio.coroutine - def start(self): + async def start(self): """Start KNX object. Connect to tunneling or Routing device.""" connection_config = self.connection_config() - yield from self.xknx.start( + await self.xknx.start( state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER], connection_config=connection_config) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) self.connected = True - @asyncio.coroutine - def stop(self, event): + async def stop(self, event): """Stop KNX object. Disconnect from tunneling or Routing device.""" - yield from self.xknx.stop() + await self.xknx.stop() def config_file(self): """Resolve and return the full path of xknx.yaml if configured.""" @@ -202,8 +218,27 @@ class KNXModule(object): self.xknx.telegram_queue.register_telegram_received_cb( self.telegram_received_cb, address_filters) - @asyncio.coroutine - def telegram_received_cb(self, telegram): + @callback + def async_create_exposures(self): + """Create exposures.""" + if CONF_KNX_EXPOSE not in self.config[DOMAIN]: + return + for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]: + expose_type = to_expose.get(CONF_KNX_EXPOSE_TYPE) + entity_id = to_expose.get(CONF_ENTITY_ID) + address = to_expose.get(CONF_KNX_EXPOSE_ADDRESS) + if expose_type in ['time', 'date', 'datetime']: + exposure = KNXExposeTime( + self.xknx, expose_type, address) + exposure.async_register() + self.exposures.append(exposure) + else: + exposure = KNXExposeSensor( + self.hass, self.xknx, expose_type, entity_id, address) + exposure.async_register() + self.exposures.append(exposure) + + async def telegram_received_cb(self, telegram): """Call invoked after a KNX telegram was received.""" self.hass.bus.fire('knx_event', { 'address': telegram.group_address.str(), @@ -212,8 +247,7 @@ class KNXModule(object): # False signals XKNX to proceed with processing telegrams. return False - @asyncio.coroutine - def service_send_to_knx_bus(self, call): + async def service_send_to_knx_bus(self, call): """Service for sending an arbitrary KNX message to the KNX bus.""" from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD) @@ -230,7 +264,7 @@ class KNXModule(object): telegram = Telegram() telegram.payload = payload telegram.group_address = address - yield from self.xknx.telegrams.put(telegram) + await self.xknx.telegrams.put(telegram) class KNXAutomation(): @@ -248,3 +282,59 @@ class KNXAutomation(): hass.data[DATA_KNX].xknx, self.script.async_run, hook=hook, counter=counter) device.actions.append(self.action) + + +class KNXExposeTime(object): + """Object to Expose Time/Date object to KNX bus.""" + + def __init__(self, xknx, expose_type, address): + """Initialize of Expose class.""" + self.xknx = xknx + self.type = expose_type + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import DateTime, DateTimeBroadcastType + broadcast_type_string = self.type.upper() + broadcast_type = DateTimeBroadcastType[broadcast_type_string] + self.device = DateTime( + self.xknx, + 'Time', + broadcast_type=broadcast_type, + group_address=self.address) + self.xknx.devices.add(self.device) + + +class KNXExposeSensor(object): + """Object to Expose HASS entity to KNX bus.""" + + def __init__(self, hass, xknx, expose_type, entity_id, address): + """Initialize of Expose class.""" + self.hass = hass + self.xknx = xknx + self.type = expose_type + self.entity_id = entity_id + self.address = address + self.device = None + + @callback + def async_register(self): + """Register listener.""" + from xknx.devices import ExposeSensor + self.device = ExposeSensor( + self.xknx, + name=self.entity_id, + group_address=self.address, + value_type=self.type) + self.xknx.devices.add(self.device) + async_track_state_change( + self.hass, self.entity_id, self._async_entity_changed) + + async def _async_entity_changed(self, entity_id, old_state, new_state): + """Callback after entity changed.""" + if new_state is None: + return + await self.device.set(float(new_state.state)) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index cfeceb0c991..a3a962a7e34 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -12,7 +12,8 @@ import os import voluptuous as vol -from homeassistant.components import group +from homeassistant.components.group import \ + ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) @@ -21,6 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers import intent from homeassistant.loader import bind_hass import homeassistant.util.color as color_util @@ -29,7 +31,7 @@ DEPENDENCIES = ['group'] SCAN_INTERVAL = timedelta(seconds=30) GROUP_NAME_ALL_LIGHTS = 'all lights' -ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights') +ENTITY_ID_ALL_LIGHTS = GROUP_ENTITY_ID_FORMAT.format('all_lights') ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -84,8 +86,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" PROP_TO_ATTR = { 'brightness': ATTR_BRIGHTNESS, 'color_temp': ATTR_COLOR_TEMP, - 'min_mireds': ATTR_MIN_MIREDS, - 'max_mireds': ATTR_MAX_MIREDS, 'rgb_color': ATTR_RGB_COLOR, 'xy_color': ATTR_XY_COLOR, 'white_value': ATTR_WHITE_VALUE, @@ -135,6 +135,8 @@ PROFILE_SCHEMA = vol.Schema( vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) ) +INTENT_SET = 'HassLightSet' + _LOGGER = logging.getLogger(__name__) @@ -206,8 +208,9 @@ def async_turn_off(hass, entity_id=None, transition=None): DOMAIN, SERVICE_TURN_OFF, data)) +@callback @bind_hass -def toggle(hass, entity_id=None, transition=None): +def async_toggle(hass, entity_id=None, transition=None): """Toggle all or specified light.""" data = { key: value for key, value in [ @@ -216,7 +219,14 @@ def toggle(hass, entity_id=None, transition=None): ] if value is not None } - hass.services.call(DOMAIN, SERVICE_TOGGLE, data) + hass.async_add_job(hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, data)) + + +@bind_hass +def toggle(hass, entity_id=None, transition=None): + """Toggle all or specified light.""" + hass.add_job(async_toggle, hass, entity_id, transition) def preprocess_turn_on_alternatives(params): @@ -228,7 +238,12 @@ def preprocess_turn_on_alternatives(params): color_name = params.pop(ATTR_COLOR_NAME, None) if color_name is not None: - params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + try: + params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name) + except ValueError: + _LOGGER.warning('Got unknown color %s, falling back to white', + color_name) + params[ATTR_RGB_COLOR] = (255, 255, 255) kelvin = params.pop(ATTR_KELVIN, None) if kelvin is not None: @@ -240,20 +255,79 @@ def preprocess_turn_on_alternatives(params): params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100) -@asyncio.coroutine -def async_setup(hass, config): +class SetIntentHandler(intent.IntentHandler): + """Handle set color intents.""" + + intent_type = INTENT_SET + slot_schema = { + vol.Required('name'): cv.string, + vol.Optional('color'): color_util.color_name_to_rgb, + vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100)) + } + + async def async_handle(self, intent_obj): + """Handle the hass intent.""" + hass = intent_obj.hass + slots = self.async_validate_slots(intent_obj.slots) + state = hass.helpers.intent.async_match_state( + slots['name']['value'], + [state for state in hass.states.async_all() + if state.domain == DOMAIN]) + + service_data = { + ATTR_ENTITY_ID: state.entity_id, + } + speech_parts = [] + + if 'color' in slots: + intent.async_test_feature( + state, SUPPORT_RGB_COLOR, 'changing colors') + service_data[ATTR_RGB_COLOR] = slots['color']['value'] + # Use original passed in value of the color because we don't have + # human readable names for that internally. + speech_parts.append('the color {}'.format( + intent_obj.slots['color']['value'])) + + if 'brightness' in slots: + intent.async_test_feature( + state, SUPPORT_BRIGHTNESS, 'changing brightness') + service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value'] + speech_parts.append('{}% brightness'.format( + slots['brightness']['value'])) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data) + + response = intent_obj.create_response() + + if not speech_parts: # No attributes changed + speech = 'Turned on {}'.format(state.name) + else: + parts = ['Changed {} to'.format(state.name)] + for index, part in enumerate(speech_parts): + if index == 0: + parts.append(' {}'.format(part)) + elif index != len(speech_parts) - 1: + parts.append(', {}'.format(part)) + else: + parts.append(' and {}'.format(part)) + speech = ''.join(parts) + + response.async_set_speech(speech) + return response + + +async def async_setup(hass, config): """Expose light control via state machine and services.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) - yield from component.async_setup(config) + await component.async_setup(config) # load profiles from files - profiles_valid = yield from Profiles.load_profiles(hass) + profiles_valid = await Profiles.load_profiles(hass) if not profiles_valid: return False - @asyncio.coroutine - def async_handle_light_service(service): + async def async_handle_light_service(service): """Handle a turn light on or off service call.""" # Get the validated data params = service.data.copy() @@ -267,18 +341,18 @@ def async_setup(hass, config): update_tasks = [] for light in target_lights: if service.service == SERVICE_TURN_ON: - yield from light.async_turn_on(**params) + await light.async_turn_on(**params) elif service.service == SERVICE_TURN_OFF: - yield from light.async_turn_off(**params) + await light.async_turn_off(**params) else: - yield from light.async_toggle(**params) + await light.async_toggle(**params) if not light.should_poll: continue update_tasks.append(light.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) # Listen for light on and light off service calls. hass.services.async_register( @@ -293,6 +367,8 @@ def async_setup(hass, config): DOMAIN, SERVICE_TOGGLE, async_handle_light_service, schema=LIGHT_TOGGLE_SCHEMA) + hass.helpers.intent.async_register(SetIntentHandler()) + return True @@ -302,8 +378,7 @@ class Profiles: _all = None @classmethod - @asyncio.coroutine - def load_profiles(cls, hass): + async def load_profiles(cls, hass): """Load and cache profiles.""" def load_profile_data(hass): """Load built-in profiles and custom profiles.""" @@ -333,7 +408,7 @@ class Profiles: return None return profiles - cls._all = yield from hass.async_add_job(load_profile_data, hass) + cls._all = await hass.async_add_job(load_profile_data, hass) return cls._all is not None @classmethod @@ -399,6 +474,10 @@ class Light(ToggleEntity): """Return optional state attributes.""" data = {} + if self.supported_features & SUPPORT_COLOR_TEMP: + data[ATTR_MIN_MIREDS] = self.min_mireds + data[ATTR_MAX_MIREDS] = self.max_mireds + if self.is_on: for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py index d01611716eb..acc70a57ff4 100644 --- a/homeassistant/components/light/demo.py +++ b/homeassistant/components/light/demo.py @@ -28,11 +28,11 @@ SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the demo light platform.""" add_devices_callback([ - DemoLight("Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, + DemoLight(1, "Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0]), - DemoLight("Ceiling Lights", True, True, + DemoLight(2, "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1]), - DemoLight("Kitchen Lights", True, True, + DemoLight(3, "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0]) ]) @@ -40,10 +40,11 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): class DemoLight(Light): """Representation of a demo light.""" - def __init__(self, name, state, available=False, rgb=None, ct=None, - brightness=180, xy_color=(.5, .5), white=200, + def __init__(self, unique_id, name, state, available=False, rgb=None, + ct=None, brightness=180, xy_color=(.5, .5), white=200, effect_list=None, effect=None): """Initialize the light.""" + self._unique_id = unique_id self._name = name self._state = state self._rgb = rgb @@ -64,6 +65,11 @@ class DemoLight(Light): """Return the name of the light if any.""" return self._name + @property + def unique_id(self): + """Return unique ID for light.""" + return self._unique_id + @property def available(self) -> bool: """Return availability.""" diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py new file mode 100644 index 00000000000..b4a5e9dddfb --- /dev/null +++ b/homeassistant/components/light/group.py @@ -0,0 +1,290 @@ +""" +This platform allows several lights to be grouped into one light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.group/ +""" +import logging +import itertools +from typing import List, Tuple, Optional, Iterator, Any, Callable +from collections import Counter + +import voluptuous as vol + +from homeassistant.core import State, callback +from homeassistant.components import light +from homeassistant.const import (STATE_ON, ATTR_ENTITY_ID, CONF_NAME, + CONF_ENTITIES, STATE_UNAVAILABLE, + ATTR_SUPPORTED_FEATURES) +from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.components.light import ( + SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_TRANSITION, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_XY_COLOR, + SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_XY_COLOR, + ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT, ATTR_FLASH, + ATTR_TRANSITION) +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Light Group' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN) +}) + +SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT + | SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION + | SUPPORT_XY_COLOR | SUPPORT_WHITE_VALUE) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None) -> None: + """Initialize light.group platform.""" + async_add_devices([LightGroup(config.get(CONF_NAME), + config[CONF_ENTITIES])]) + + +class LightGroup(light.Light): + """Representation of a light group.""" + + def __init__(self, name: str, entity_ids: List[str]) -> None: + """Initialize a light group.""" + self._name = name # type: str + self._entity_ids = entity_ids # type: List[str] + self._is_on = False # type: bool + self._available = False # type: bool + self._brightness = None # type: Optional[int] + self._xy_color = None # type: Optional[Tuple[float, float]] + self._rgb_color = None # type: Optional[Tuple[int, int, int]] + self._color_temp = None # type: Optional[int] + self._min_mireds = 154 # type: Optional[int] + self._max_mireds = 500 # type: Optional[int] + self._white_value = None # type: Optional[int] + self._effect_list = None # type: Optional[List[str]] + self._effect = None # type: Optional[str] + self._supported_features = 0 # type: int + self._async_unsub_state_changed = None + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + @callback + def async_state_changed_listener(entity_id: str, old_state: State, + new_state: State): + """Handle child updates.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_state_changed = async_track_state_change( + self.hass, self._entity_ids, async_state_changed_listener) + await self.async_update() + + async def async_will_remove_from_hass(self): + """Callback when removed from HASS.""" + if self._async_unsub_state_changed is not None: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def is_on(self) -> bool: + """Return the on/off state of the light group.""" + return self._is_on + + @property + def available(self) -> bool: + """Return whether the light group is available.""" + return self._available + + @property + def brightness(self) -> Optional[int]: + """Return the brightness of this light group between 0..255.""" + return self._brightness + + @property + def xy_color(self) -> Optional[Tuple[float, float]]: + """Return the XY color value [float, float].""" + return self._xy_color + + @property + def rgb_color(self) -> Optional[Tuple[int, int, int]]: + """Return the RGB color value [int, int, int].""" + return self._rgb_color + + @property + def color_temp(self) -> Optional[int]: + """Return the CT color value in mireds.""" + return self._color_temp + + @property + def min_mireds(self) -> Optional[int]: + """Return the coldest color_temp that this light group supports.""" + return self._min_mireds + + @property + def max_mireds(self) -> Optional[int]: + """Return the warmest color_temp that this light group supports.""" + return self._max_mireds + + @property + def white_value(self) -> Optional[int]: + """Return the white value of this light group between 0..255.""" + return self._white_value + + @property + def effect_list(self) -> Optional[List[str]]: + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self) -> Optional[str]: + """Return the current effect.""" + return self._effect + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def should_poll(self) -> bool: + """No polling needed for a light group.""" + return False + + async def async_turn_on(self, **kwargs): + """Forward the turn_on command to all lights in the light group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_BRIGHTNESS in kwargs: + data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] + + if ATTR_XY_COLOR in kwargs: + data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR] + + if ATTR_RGB_COLOR in kwargs: + data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR] + + if ATTR_COLOR_TEMP in kwargs: + data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] + + if ATTR_WHITE_VALUE in kwargs: + data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] + + if ATTR_EFFECT in kwargs: + data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + if ATTR_FLASH in kwargs: + data[ATTR_FLASH] = kwargs[ATTR_FLASH] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True) + + async def async_turn_off(self, **kwargs): + """Forward the turn_off command to all lights in the light group.""" + data = {ATTR_ENTITY_ID: self._entity_ids} + + if ATTR_TRANSITION in kwargs: + data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION] + + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True) + + async def async_update(self): + """Query all members and determine the light group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + states = list(filter(None, all_states)) + on_states = [state for state in states if state.state == STATE_ON] + + self._is_on = len(on_states) > 0 + self._available = any(state.state != STATE_UNAVAILABLE + for state in states) + + self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) + + self._xy_color = _reduce_attribute( + on_states, ATTR_XY_COLOR, reduce=_mean_tuple) + + self._rgb_color = _reduce_attribute( + on_states, ATTR_RGB_COLOR, reduce=_mean_tuple) + if self._rgb_color is not None: + self._rgb_color = tuple(map(int, self._rgb_color)) + + self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) + + self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) + self._min_mireds = _reduce_attribute( + states, ATTR_MIN_MIREDS, default=154, reduce=min) + self._max_mireds = _reduce_attribute( + states, ATTR_MAX_MIREDS, default=500, reduce=max) + + self._effect_list = None + all_effect_lists = list( + _find_state_attributes(states, ATTR_EFFECT_LIST)) + if all_effect_lists: + # Merge all effects from all effect_lists with a union merge. + self._effect_list = list(set().union(*all_effect_lists)) + + self._effect = None + all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) + if all_effects: + # Report the most common effect. + effects_count = Counter(itertools.chain(all_effects)) + self._effect = effects_count.most_common(1)[0][0] + + self._supported_features = 0 + for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): + # Merge supported features by emulating support for every feature + # we find. + self._supported_features |= support + # Bitwise-and the supported features with the GroupedLight's features + # so that we don't break in the future when a new feature is added. + self._supported_features &= SUPPORT_GROUP_LIGHT + + +def _find_state_attributes(states: List[State], + key: str) -> Iterator[Any]: + """Find attributes with matching key from states.""" + for state in states: + value = state.attributes.get(key) + if value is not None: + yield value + + +def _mean_int(*args): + """Return the mean of the supplied values.""" + return int(sum(args) / len(args)) + + +def _mean_tuple(*args): + """Return the mean values along the columns of the supplied values.""" + return tuple(sum(l) / len(l) for l in zip(*args)) + + +# https://github.com/PyCQA/pylint/issues/1831 +# pylint: disable=bad-whitespace +def _reduce_attribute(states: List[State], + key: str, + default: Optional[Any] = None, + reduce: Callable[..., Any] = _mean_int) -> Any: + """Find the first attribute matching key from states. + + If none are found, return default. + """ + attrs = list(_find_state_attributes(states, key)) + + if not attrs: + return default + + if len(attrs) == 1: + return attrs[0] + + return reduce(*attrs) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index ffca48743e9..bee6840f346 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -287,22 +287,21 @@ class HueLight(Light): if self.info.get('manufacturername') == 'OSRAM': color_hue, sat = color_util.color_xy_to_hs( *kwargs[ATTR_XY_COLOR]) - command['hue'] = color_hue - command['sat'] = sat + command['hue'] = color_hue / 360 * 65535 + command['sat'] = sat / 100 * 255 else: command['xy'] = kwargs[ATTR_XY_COLOR] elif ATTR_RGB_COLOR in kwargs: if self.info.get('manufacturername') == 'OSRAM': hsv = color_util.color_RGB_to_hsv( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) - command['hue'] = hsv[0] - command['sat'] = hsv[1] - command['bri'] = hsv[2] + command['hue'] = hsv[0] / 360 * 65535 + command['sat'] = hsv[1] / 100 * 255 + command['bri'] = hsv[2] / 100 * 255 else: xyb = color_util.color_RGB_to_xy( *(int(val) for val in kwargs[ATTR_RGB_COLOR])) command['xy'] = xyb[0], xyb[1] - command['bri'] = xyb[2] elif ATTR_COLOR_TEMP in kwargs: temp = kwargs[ATTR_COLOR_TEMP] command['ct'] = max(self.min_mireds, min(temp, self.max_mireds)) diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 4701866cd9a..2057192299e 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -213,9 +213,10 @@ class Hyperion(Light): except (KeyError, IndexError): pass - if not response['info']['activeLedColor']: + led_color = response['info']['activeLedColor'] + if not led_color or led_color[0]['RGB value'] == [0, 0, 0]: # Get the active effect - if response['info']['activeEffects']: + if response['info'].get('activeEffects'): self._rgb_color = [175, 0, 255] self._icon = 'mdi:lava-lamp' try: diff --git a/homeassistant/components/light/insteon_plm.py b/homeassistant/components/light/insteon_plm.py index f0ef0ce1b7e..40453da38e5 100644 --- a/homeassistant/components/light/insteon_plm.py +++ b/homeassistant/components/light/insteon_plm.py @@ -2,15 +2,14 @@ Support for Insteon lights via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/light.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback +from homeassistant.components.insteon_plm import InsteonPLMEntity from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.loader import get_component _LOGGER = logging.getLogger(__name__) @@ -24,96 +23,47 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Insteon PLM device.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') - dimmable = bool('dimmable' in device.get('capabilities')) + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info("Registered %s with light platform", name) + _LOGGER.debug('Adding device %s entity %s to Light platform', + device.address.hex, device.states[state_key].name) - device_list.append( - InsteonPLMDimmerDevice(hass, plm, address, name, dimmable) - ) + new_entity = InsteonPLMDimmerDevice(device, state_key) - async_add_devices(device_list) + async_add_devices([new_entity]) -class InsteonPLMDimmerDevice(Light): +class InsteonPLMDimmerDevice(InsteonPLMEntity, Light): """A Class for an Insteon device.""" - def __init__(self, hass, plm, address, name, dimmable): - """Initialize the light.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - self._dimmable = dimmable - - self._plm.add_update_callback( - self.async_light_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name - @property def brightness(self): """Return the brightness of this light between 0..255.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) + onlevel = self._insteon_device_state.value return int(onlevel) @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug("on level for %s is %s", self._address, onlevel) - return bool(onlevel) + return bool(self.brightness) @property def supported_features(self): """Flag supported features.""" - if self._dimmable: - return SUPPORT_BRIGHTNESS - - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_light_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info("Received update callback from PLM for %s", self._address) - self._hass.async_add_job(self.async_update_ha_state()) + return SUPPORT_BRIGHTNESS @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" if ATTR_BRIGHTNESS in kwargs: brightness = int(kwargs[ATTR_BRIGHTNESS]) + self._insteon_device_state.set_level(brightness) else: - brightness = MAX_BRIGHTNESS - self._plm.turn_on(self._address, brightness=brightness) + self._insteon_device_state.on() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn device off.""" - self._plm.turn_off(self._address) + self._insteon_device_state.off() diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 020184b8501..83083e34bad 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP lights. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.knx/ """ -import asyncio import voluptuous as vol @@ -37,8 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up lights for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -86,11 +85,10 @@ class KNXLight(Light): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -111,8 +109,8 @@ class KNXLight(Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self.device.brightness \ - if self.device.supports_dimming else \ + return self.device.current_brightness \ + if self.device.supports_brightness else \ None @property @@ -124,7 +122,7 @@ class KNXLight(Light): def rgb_color(self): """Return the RBG color value.""" if self.device.supports_color: - return self.device.current_color() + return self.device.current_color return None @property @@ -156,23 +154,23 @@ class KNXLight(Light): def supported_features(self): """Flag supported features.""" flags = 0 - if self.device.supports_dimming: + if self.device.supports_brightness: flags |= SUPPORT_BRIGHTNESS if self.device.supports_color: flags |= SUPPORT_RGB_COLOR return flags - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming: - yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) + if ATTR_BRIGHTNESS in kwargs: + if self.device.supports_brightness: + await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS])) elif ATTR_RGB_COLOR in kwargs: - yield from self.device.set_color(kwargs[ATTR_RGB_COLOR]) + if self.device.supports_color: + await self.device.set_color(kwargs[ATTR_RGB_COLOR]) else: - yield from self.device.set_on() + await self.device.set_on() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the light off.""" - yield from self.device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index 71a261e3806..18bc39d88d2 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -123,8 +123,10 @@ def aiolifx_effects(): return aiolifx_effects_module -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up the LIFX platform.""" if sys.platform == 'win32': _LOGGER.warning("The lifx platform is known to not work on Windows. " @@ -169,13 +171,15 @@ def find_hsbk(**kwargs): if ATTR_RGB_COLOR in kwargs: hue, saturation, brightness = \ color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR]) - saturation = convert_8_to_16(saturation) - brightness = convert_8_to_16(brightness) + hue = int(hue / 360 * 65535) + saturation = int(saturation / 100 * 65535) + brightness = int(brightness / 100 * 65535) kelvin = 3500 if ATTR_XY_COLOR in kwargs: hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR]) - saturation = convert_8_to_16(saturation) + hue = int(hue / 360 * 65535) + saturation = int(saturation / 100 * 65535) kelvin = 3500 if ATTR_COLOR_TEMP in kwargs: @@ -212,8 +216,7 @@ class LIFXManager(object): def register_set_state(self): """Register the LIFX set_state service call.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service.""" tasks = [] for light in self.service_to_entities(service): @@ -221,36 +224,34 @@ class LIFXManager(object): task = light.async_set_state(**service.data) tasks.append(self.hass.async_add_job(task)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self.hass.services.async_register( - DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle, + DOMAIN, SERVICE_LIFX_SET_STATE, service_handler, schema=LIFX_SET_STATE_SCHEMA) def register_effects(self): """Register the LIFX effects as hass service calls.""" - @asyncio.coroutine - def async_service_handle(service): + async def service_handler(service): """Apply a service, i.e. start an effect.""" entities = self.service_to_entities(service) if entities: - yield from self.start_effect( + await self.start_effect( entities, service.service, **service.data) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle, + DOMAIN, SERVICE_EFFECT_PULSE, service_handler, schema=LIFX_EFFECT_PULSE_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle, + DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler, schema=LIFX_EFFECT_COLORLOOP_SCHEMA) self.hass.services.async_register( - DOMAIN, SERVICE_EFFECT_STOP, async_service_handle, + DOMAIN, SERVICE_EFFECT_STOP, service_handler, schema=LIFX_EFFECT_STOP_SCHEMA) - @asyncio.coroutine - def start_effect(self, entities, service, **kwargs): + async def start_effect(self, entities, service, **kwargs): """Start a light effect on entities.""" devices = list(map(lambda l: l.device, entities)) @@ -262,7 +263,7 @@ class LIFXManager(object): mode=kwargs.get(ATTR_MODE), hsbk=find_hsbk(**kwargs), ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_COLORLOOP: preprocess_turn_on_alternatives(kwargs) @@ -278,9 +279,9 @@ class LIFXManager(object): transition=kwargs.get(ATTR_TRANSITION), brightness=brightness, ) - yield from self.effects_conductor.start(effect, devices) + await self.effects_conductor.start(effect, devices) elif service == SERVICE_EFFECT_STOP: - yield from self.effects_conductor.stop(devices) + await self.effects_conductor.stop(devices) def service_to_entities(self, service): """Return the known devices that a service call mentions.""" @@ -295,25 +296,24 @@ class LIFXManager(object): @callback def register(self, device): - """Handle newly detected bulb.""" - self.hass.async_add_job(self.async_register(device)) + """Handle aiolifx detected bulb.""" + self.hass.async_add_job(self.register_new_device(device)) - @asyncio.coroutine - def async_register(self, device): + async def register_new_device(self, device): """Handle newly detected bulb.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] entity.registered = True _LOGGER.debug("%s register AGAIN", entity.who) - yield from entity.update_hass() + await entity.update_hass() else: _LOGGER.debug("%s register NEW", device.ip_addr) # Read initial state ack = AwaitAioLIFX().wait - version_resp = yield from ack(device.get_version) + version_resp = await ack(device.get_version) if version_resp: - color_resp = yield from ack(device.get_color) + color_resp = await ack(device.get_color) if version_resp is None or color_resp is None: _LOGGER.error("Failed to initialize %s", device.ip_addr) @@ -335,7 +335,7 @@ class LIFXManager(object): @callback def unregister(self, device): - """Handle disappearing bulbs.""" + """Handle aiolifx disappearing bulbs.""" if device.mac_addr in self.entities: entity = self.entities[device.mac_addr] _LOGGER.debug("%s unregister", entity.who) @@ -359,15 +359,14 @@ class AwaitAioLIFX: self.message = message self.event.set() - @asyncio.coroutine - def wait(self, method): + async def wait(self, method): """Call an aiolifx method and wait for its response.""" self.device = None self.message = None self.event.clear() method(callb=self.callback) - yield from self.event.wait() + await self.event.wait() return self.message @@ -464,21 +463,19 @@ class LIFXLight(Light): return 'lifx_effect_' + effect.name return None - @asyncio.coroutine - def update_hass(self, now=None): + async def update_hass(self, now=None): """Request new status and push it to hass.""" self.postponed_update = None - yield from self.async_update() - yield from self.async_update_ha_state() + await self.async_update() + await self.async_update_ha_state() - @asyncio.coroutine - def update_during_transition(self, when): + async def update_during_transition(self, when): """Update state at the start and end of a transition.""" if self.postponed_update: self.postponed_update() # Transition has started - yield from self.update_hass() + await self.update_hass() # Transition has ended if when > 0: @@ -486,28 +483,25 @@ class LIFXLight(Light): self.hass, self.update_hass, util.dt.utcnow() + timedelta(milliseconds=when)) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" kwargs[ATTR_POWER] = True - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" kwargs[ATTR_POWER] = False - self.hass.async_add_job(self.async_set_state(**kwargs)) + self.hass.async_add_job(self.set_state(**kwargs)) - @asyncio.coroutine - def async_set_state(self, **kwargs): + async def set_state(self, **kwargs): """Set a color on the light and turn it on/off.""" - with (yield from self.lock): + async with self.lock: bulb = self.device - yield from self.effects_conductor.stop([bulb]) + await self.effects_conductor.stop([bulb]) if ATTR_EFFECT in kwargs: - yield from self.default_effect(**kwargs) + await self.default_effect(**kwargs) return if ATTR_INFRARED in kwargs: @@ -529,51 +523,47 @@ class LIFXLight(Light): if not self.is_on: if power_off: - yield from self.set_power(ack, False) + await self.set_power(ack, False) if hsbk: - yield from self.set_color(ack, hsbk, kwargs) + await self.set_color(ack, hsbk, kwargs) if power_on: - yield from self.set_power(ack, True, duration=fade) + await self.set_power(ack, True, duration=fade) else: if power_on: - yield from self.set_power(ack, True) + await self.set_power(ack, True) if hsbk: - yield from self.set_color(ack, hsbk, kwargs, duration=fade) + await self.set_color(ack, hsbk, kwargs, duration=fade) if power_off: - yield from self.set_power(ack, False, duration=fade) + await self.set_power(ack, False, duration=fade) # Avoid state ping-pong by holding off updates as the state settles - yield from asyncio.sleep(0.3) + await asyncio.sleep(0.3) # Update when the transition starts and ends - yield from self.update_during_transition(fade) + await self.update_during_transition(fade) - @asyncio.coroutine - def set_power(self, ack, pwr, duration=0): + async def set_power(self, ack, pwr, duration=0): """Send a power change to the device.""" - yield from ack(partial(self.device.set_power, pwr, duration=duration)) + await ack(partial(self.device.set_power, pwr, duration=duration)) - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" hsbk = merge_hsbk(self.device.color, hsbk) - yield from ack(partial(self.device.set_color, hsbk, duration=duration)) + await ack(partial(self.device.set_color, hsbk, duration=duration)) - @asyncio.coroutine - def default_effect(self, **kwargs): + async def default_effect(self, **kwargs): """Start an effect with default parameters.""" service = kwargs[ATTR_EFFECT] data = { ATTR_ENTITY_ID: self.entity_id, } - yield from self.hass.services.async_call(DOMAIN, service, data) + await self.hass.services.async_call(DOMAIN, service, data) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update bulb status.""" _LOGGER.debug("%s async_update", self.who) if self.available and not self.lock.locked(): - yield from AwaitAioLIFX().wait(self.device.get_color) + await AwaitAioLIFX().wait(self.device.get_color) class LIFXWhite(LIFXLight): @@ -612,15 +602,17 @@ class LIFXColor(LIFXLight): """Return the RGB value.""" hue, sat, bri, _ = self.device.color - return color_util.color_hsv_to_RGB( - hue, convert_16_to_8(sat), convert_16_to_8(bri)) + hue = hue / 65535 * 360 + sat = sat / 65535 * 100 + bri = bri / 65535 * 100 + + return color_util.color_hsv_to_RGB(hue, sat, bri) class LIFXStrip(LIFXColor): """Representation of a LIFX light strip with multiple zones.""" - @asyncio.coroutine - def set_color(self, ack, hsbk, kwargs, duration=0): + async def set_color(self, ack, hsbk, kwargs, duration=0): """Send a color change to the device.""" bulb = self.device num_zones = len(bulb.color_zones) @@ -630,7 +622,7 @@ class LIFXStrip(LIFXColor): # Fast track: setting all zones to the same brightness and color # can be treated as a single-zone bulb. if hsbk[2] is not None and hsbk[3] is not None: - yield from super().set_color(ack, hsbk, kwargs, duration) + await super().set_color(ack, hsbk, kwargs, duration) return zones = list(range(0, num_zones)) @@ -639,11 +631,11 @@ class LIFXStrip(LIFXColor): # Zone brightness is not reported when powered off if not self.is_on and hsbk[2] is None: - yield from self.set_power(ack, True) - yield from asyncio.sleep(0.3) - yield from self.update_color_zones() - yield from self.set_power(ack, False) - yield from asyncio.sleep(0.3) + await self.set_power(ack, True) + await asyncio.sleep(0.3) + await self.update_color_zones() + await self.set_power(ack, False) + await asyncio.sleep(0.3) # Send new color to each zone for index, zone in enumerate(zones): @@ -655,23 +647,21 @@ class LIFXStrip(LIFXColor): color=zone_hsbk, duration=duration, apply=apply) - yield from ack(set_zone) + await ack(set_zone) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update strip status.""" if self.available and not self.lock.locked(): - yield from super().async_update() - yield from self.update_color_zones() + await super().async_update() + await self.update_color_zones() - @asyncio.coroutine - def update_color_zones(self): + async def update_color_zones(self): """Get updated color information for each zone.""" zone = 0 top = 1 while self.available and zone < top: # Each get_color_zones can update 8 zones at once - resp = yield from AwaitAioLIFX().wait(partial( + resp = await AwaitAioLIFX().wait(partial( self.device.get_color_zones, start_index=zone)) if resp: diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 0606d097d49..f011792a15c 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -17,6 +17,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA) import homeassistant.helpers.config_validation as cv +from homeassistant.util.color import color_temperature_mired_to_kelvin from homeassistant.helpers.restore_state import async_get_last_state REQUIREMENTS = ['limitlessled==1.1.0'] @@ -222,6 +223,16 @@ class LimitlessLEDGroup(Light): """Return the brightness property.""" return self._brightness + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 154 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 370 + @property def color_temp(self): """Return the temperature property.""" @@ -310,8 +321,11 @@ class LimitlessLEDGroup(Light): def limitlessled_temperature(self): """Convert Home Assistant color temperature units to percentage.""" - width = self.max_mireds - self.min_mireds - temperature = 1 - (self._temperature - self.min_mireds) / width + max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds) + min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds) + width = max_kelvin - min_kelvin + kelvin = color_temperature_mired_to_kelvin(self._temperature) + temperature = (kelvin - min_kelvin) / width return max(0, min(1, temperature)) def limitlessled_brightness(self): diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 0bcf6933e68..44e887e62c4 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -169,3 +169,13 @@ xiaomi_miio_set_scene: scene: description: Number of the fixed scene, between 1 and 4. example: 1 + +xiaomi_miio_set_delayed_turn_off: + description: Delayed turn off. + fields: + entity_id: + description: Name of the light entity. + example: 'light.xiaomi_miio' + time_period: + description: Time period for the delayed turn off. + example: "5, '0:05', {'minutes': 5}" diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index eaf41691903..d9b7d6c76db 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -8,6 +8,8 @@ import asyncio from functools import partial import logging from math import ceil +from datetime import timedelta +import datetime import voluptuous as vol @@ -18,16 +20,24 @@ from homeassistant.components.light import ( from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, ) from homeassistant.exceptions import PlatformNotReady +from homeassistant.util import dt _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Philips Light' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'light.xiaomi_miio' + +CONF_MODEL = 'model' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + ['philips.light.sread1', + 'philips.light.ceiling', + 'philips.light.zyceiling', + 'philips.light.bulb']), }) REQUIREMENTS = ['python-miio==0.3.7'] @@ -36,25 +46,38 @@ REQUIREMENTS = ['python-miio==0.3.7'] CCT_MIN = 1 CCT_MAX = 100 +DELAYED_TURN_OFF_MAX_DEVIATION = 4 + SUCCESS = ['ok'] ATTR_MODEL = 'model' ATTR_SCENE = 'scene' +ATTR_DELAYED_TURN_OFF = 'delayed_turn_off' +ATTR_TIME_PERIOD = 'time_period' SERVICE_SET_SCENE = 'xiaomi_miio_set_scene' +SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off' XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, }) -SERVICE_SCHEMA_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ +SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ vol.Required(ATTR_SCENE): vol.All(vol.Coerce(int), vol.Clamp(min=1, max=4)) }) +SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_TIME_PERIOD): + vol.All(cv.time_period, cv.positive_timedelta) +}) + SERVICE_TO_METHOD = { + SERVICE_SET_DELAYED_TURN_OFF: { + 'method': 'async_set_delayed_turn_off', + 'schema': SERVICE_SCHEMA_SET_DELAYED_TURN_OFF}, SERVICE_SET_SCENE: { 'method': 'async_set_scene', - 'schema': SERVICE_SCHEMA_SCENE} + 'schema': SERVICE_SCHEMA_SET_SCENE}, } @@ -63,46 +86,48 @@ SERVICE_TO_METHOD = { def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the light from config.""" from miio import Device, DeviceException - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) + model = config.get(CONF_MODEL) _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - try: - light = Device(host, token) - device_info = light.info() - _LOGGER.info("%s %s %s initialized", - device_info.model, - device_info.firmware_version, - device_info.hardware_version) + if model is None: + try: + miio_device = Device(host, token) + device_info = miio_device.info() + model = device_info.model + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady - if device_info.model == 'philips.light.sread1': - from miio import PhilipsEyecare - light = PhilipsEyecare(host, token) - device = XiaomiPhilipsEyecareLamp(name, light, device_info) - elif device_info.model == 'philips.light.ceiling': - from miio import Ceil - light = Ceil(host, token) - device = XiaomiPhilipsCeilingLamp(name, light, device_info) - elif device_info.model == 'philips.light.bulb': - from miio import PhilipsBulb - light = PhilipsBulb(host, token) - device = XiaomiPhilipsLightBall(name, light, device_info) - else: - _LOGGER.error( - 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' - 'and provide the following data: %s', device_info.model) - return False + if model == 'philips.light.sread1': + from miio import PhilipsEyecare + light = PhilipsEyecare(host, token) + device = XiaomiPhilipsEyecareLamp(name, light, model) + elif model in ['philips.light.ceiling', 'philips.light.zyceiling']: + from miio import Ceil + light = Ceil(host, token) + device = XiaomiPhilipsCeilingLamp(name, light, model) + elif model == 'philips.light.bulb': + from miio import PhilipsBulb + light = PhilipsBulb(host, token) + device = XiaomiPhilipsLightBall(name, light, model) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', model) + return False - except DeviceException: - raise PlatformNotReady - - hass.data[PLATFORM][host] = device + hass.data[DATA_KEY][host] = device async_add_devices([device], update_before_add=True) @asyncio.coroutine @@ -113,10 +138,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) if entity_ids: - target_devices = [dev for dev in hass.data[PLATFORM].values() + target_devices = [dev for dev in hass.data[DATA_KEY].values() if dev.entity_id in entity_ids] else: - target_devices = hass.data[PLATFORM].values() + target_devices = hass.data[DATA_KEY].values() update_tasks = [] for target_device in target_devices: @@ -136,10 +161,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPhilipsGenericLight(Light): """Representation of a Xiaomi Philips Light.""" - def __init__(self, name, light, device_info): + def __init__(self, name, light, model): """Initialize the light device.""" self._name = name - self._device_info = device_info + self._model = model self._brightness = None self._color_temp = None @@ -147,7 +172,9 @@ class XiaomiPhilipsGenericLight(Light): self._light = light self._state = None self._state_attrs = { - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, + ATTR_SCENE: None, + ATTR_DELAYED_TURN_OFF: None, } @property @@ -217,14 +244,14 @@ class XiaomiPhilipsGenericLight(Light): if result: self._brightness = brightness - - self._state = yield from self._try_command( - "Turning the light on failed.", self._light.on) + else: + yield from self._try_command( + "Turning the light on failed.", self._light.on) @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the light off.""" - self._state = yield from self._try_command( + yield from self._try_command( "Turning the light off failed.", self._light.off) @asyncio.coroutine @@ -236,9 +263,20 @@ class XiaomiPhilipsGenericLight(Light): _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = ceil((255/100.0) * state.brightness) + self._brightness = ceil((255 / 100.0) * state.brightness) + + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) @asyncio.coroutine @@ -248,6 +286,13 @@ class XiaomiPhilipsGenericLight(Light): "Setting a fixed scene failed.", self._light.set_scene, scene) + @asyncio.coroutine + def async_set_delayed_turn_off(self, time_period: timedelta): + """Set delay off. The unit is different per device.""" + yield from self._try_command( + "Setting the delay off failed.", + self._light.delay_off, time_period.total_seconds()) + @staticmethod def translate(value, left_min, left_max, right_min, right_max): """Map a value from left span to right span.""" @@ -256,6 +301,28 @@ class XiaomiPhilipsGenericLight(Light): value_scaled = float(value - left_min) / float(left_span) return int(right_min + (value_scaled * right_span)) + @staticmethod + def delayed_turn_off_timestamp(countdown: int, + current: datetime, + previous: datetime): + """Update the turn off timestamp only if necessary.""" + if countdown > 0: + new = current.replace(microsecond=0) + \ + timedelta(seconds=countdown) + + if previous is None: + return new + + lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION) + upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION) + diff = previous - new + if lower < diff < upper: + return previous + + return new + + return None + class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): """Representation of a Xiaomi Philips Light Ball.""" @@ -339,7 +406,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): self._brightness = brightness else: - self._state = yield from self._try_command( + yield from self._try_command( "Turning the light on failed.", self._light.on) @asyncio.coroutine @@ -351,13 +418,24 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light): _LOGGER.debug("Got new state: %s", state) self._state = state.is_on - self._brightness = ceil((255/100.0) * state.brightness) + self._brightness = ceil((255 / 100.0) * state.brightness) self._color_temp = self.translate( state.color_temperature, CCT_MIN, CCT_MAX, self.max_mireds, self.min_mireds) + delayed_turn_off = self.delayed_turn_off_timestamp( + state.delay_off_countdown, + dt.utcnow(), + self._state_attrs[ATTR_DELAYED_TURN_OFF]) + + self._state_attrs.update({ + ATTR_SCENE: state.scene, + ATTR_DELAYED_TURN_OFF: delayed_turn_off, + }) + except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index e6e447884cb..d0b944793c4 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) +ALL_EVENT_TYPES = [ + EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +] + GROUP_BY_MINUTES = 15 CONTINUOUS_DOMAINS = ['proximity', 'sensor'] @@ -266,15 +271,18 @@ def humanify(events): def _get_events(hass, config, start_day, end_day): """Get events for a period of time.""" - from homeassistant.components.recorder.models import Events + from homeassistant.components.recorder.models import Events, States from homeassistant.components.recorder.util import ( execute, session_scope) with session_scope(hass=hass) as session: - query = session.query(Events).order_by( - Events.time_fired).filter( - (Events.time_fired > start_day) & - (Events.time_fired < end_day)) + query = session.query(Events).order_by(Events.time_fired) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ + .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ + .filter((Events.time_fired > start_day) + & (Events.time_fired < end_day)) \ + .filter((States.last_updated == States.last_changed) + | (States.state_id.is_(None))) events = execute(query) return humanify(_exclude_events(events, config)) diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index c2c70984734..37a50b39e95 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -25,6 +25,10 @@ DEPENDENCIES = ['apple_tv'] _LOGGER = logging.getLogger(__name__) +SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \ + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ + SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK + @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -79,7 +83,7 @@ class AppleTvDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self.atv.metadata.device_id @property @@ -196,14 +200,7 @@ class AppleTvDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA - if self._playing is None or self.state == STATE_IDLE: - return features - - features |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \ - SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK - - return features + return SUPPORT_APPLE_TV @asyncio.coroutine def async_turn_on(self): diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index d3cf2f7b501..dbcb53ec185 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ # pylint: disable=import-error -import asyncio import logging import threading import functools @@ -135,9 +134,8 @@ def _async_create_cast_device(hass, chromecast): return None -@asyncio.coroutine -def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_devices, discovery_info=None): +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info=None): """Set up the cast platform.""" import pychromecast @@ -187,7 +185,7 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType, try: func = functools.partial(pychromecast.Chromecast, *want_host, tries=SOCKET_CLIENT_RETRIES) - chromecast = yield from hass.async_add_job(func) + chromecast = await hass.async_add_job(func) except pychromecast.ChromecastConnectionError as err: _LOGGER.warning("Can't set up chromecast on %s: %s", want_host[0], err) @@ -420,7 +418,7 @@ class CastDevice(MediaPlayerDevice): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" if self.cast.uuid is not None: return str(self.cast.uuid) return None @@ -439,8 +437,7 @@ class CastDevice(MediaPlayerDevice): self.cast_status = self.cast.status self.media_status = self.cast.media_controller.status - @asyncio.coroutine - def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" self._async_disconnect() diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py new file mode 100644 index 00000000000..eda47237b44 --- /dev/null +++ b/homeassistant/components/media_player/channels.py @@ -0,0 +1,303 @@ +""" +Support for interfacing with an instance of Channels (https://getchannels.com). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.channels/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE, + MEDIA_TYPE_VIDEO, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, + SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE, DOMAIN, PLATFORM_SCHEMA, + MediaPlayerDevice) + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, + ATTR_ENTITY_ID) + +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DATA_CHANNELS = 'channels' +DEFAULT_NAME = 'Channels' +DEFAULT_PORT = 57000 + +FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + +SERVICE_SEEK_FORWARD = 'channels_seek_forward' +SERVICE_SEEK_BACKWARD = 'channels_seek_backward' +SERVICE_SEEK_BY = 'channels_seek_by' + +# Service call validation schemas +ATTR_SECONDS = 'seconds' + +CHANNELS_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, +}) + +CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({ + vol.Required(ATTR_SECONDS): vol.Coerce(int), +}) + +REQUIREMENTS = ['pychannels==1.0.0'] + + +# pylint: disable=unused-argument, abstract-method +# pylint: disable=too-many-instance-attributes +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Channels platform.""" + device = ChannelsPlayer( + config.get('name', DEFAULT_NAME), + config.get(CONF_HOST), + config.get(CONF_PORT, DEFAULT_PORT) + ) + + if DATA_CHANNELS not in hass.data: + hass.data[DATA_CHANNELS] = [] + + add_devices([device], True) + hass.data[DATA_CHANNELS].append(device) + + def service_handler(service): + """Handler for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + + if entity_ids: + devices = [device for device in hass.data[DATA_CHANNELS] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_CHANNELS] + + for device in devices: + if service.service == SERVICE_SEEK_FORWARD: + device.seek_forward() + elif service.service == SERVICE_SEEK_BACKWARD: + device.seek_backward() + elif service.service == SERVICE_SEEK_BY: + seconds = service.data.get('seconds') + device.seek_by(seconds) + + hass.services.register( + DOMAIN, SERVICE_SEEK_FORWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, + schema=CHANNELS_SCHEMA) + + hass.services.register( + DOMAIN, SERVICE_SEEK_BY, service_handler, + schema=CHANNELS_SEEK_BY_SCHEMA) + + +class ChannelsPlayer(MediaPlayerDevice): + """Representation of a Channels instance.""" + + # pylint: disable=too-many-public-methods + def __init__(self, name, host, port): + """Initialize the Channels app.""" + from pychannels import Channels + + self._name = name + self._host = host + self._port = port + + self.client = Channels(self._host, self._port) + + self.status = None + self.muted = None + + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + self.favorite_channels = [] + + def update_favorite_channels(self): + """Update the favorite channels from the client.""" + self.favorite_channels = self.client.favorite_channels() + + def update_state(self, state_hash): + """Update all the state properties with the passed in dictionary.""" + self.status = state_hash.get('status', "stopped") + self.muted = state_hash.get('muted', False) + + channel_hash = state_hash.get('channel') + np_hash = state_hash.get('now_playing') + + if channel_hash: + self.channel_number = channel_hash.get('channel_number') + self.channel_name = channel_hash.get('channel_name') + self.channel_image_url = channel_hash.get('channel_image_url') + else: + self.channel_number = None + self.channel_name = None + self.channel_image_url = None + + if np_hash: + self.now_playing_title = np_hash.get('title') + self.now_playing_episode_title = np_hash.get('episode_title') + self.now_playing_season_number = np_hash.get('season_number') + self.now_playing_episode_number = np_hash.get('episode_number') + self.now_playing_summary = np_hash.get('summary') + self.now_playing_image_url = np_hash.get('image_url') + else: + self.now_playing_title = None + self.now_playing_episode_title = None + self.now_playing_season_number = None + self.now_playing_episode_number = None + self.now_playing_summary = None + self.now_playing_image_url = None + + @property + def name(self): + """Return the name of the player.""" + return self._name + + @property + def state(self): + """Return the state of the player.""" + if self.status == 'stopped': + return STATE_IDLE + + if self.status == 'paused': + return STATE_PAUSED + + if self.status == 'playing': + return STATE_PLAYING + + return None + + def update(self): + """Retrieve latest state.""" + self.update_favorite_channels() + self.update_state(self.client.status()) + + @property + def source_list(self): + """List of favorite channels.""" + sources = [channel['name'] for channel in self.favorite_channels] + return sources + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.muted + + @property + def media_content_id(self): + """Content ID of current playing channel.""" + return self.channel_number + + @property + def media_content_type(self): + """Content type of current playing media.""" + return MEDIA_TYPE_CHANNEL + + @property + def media_image_url(self): + """Image url of current playing media.""" + if self.now_playing_image_url: + return self.now_playing_image_url + elif self.channel_image_url: + return self.channel_image_url + + return 'https://getchannels.com/assets/img/icon-1024.png' + + @property + def media_title(self): + """Title of current playing media.""" + if self.state: + return self.now_playing_title + + return None + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return FEATURE_SUPPORT + + def mute_volume(self, mute): + """Mute (true) or unmute (false) player.""" + if mute != self.muted: + response = self.client.toggle_muted() + self.update_state(response) + + def media_stop(self): + """Send media_stop command to player.""" + self.status = "stopped" + response = self.client.stop() + self.update_state(response) + + def media_play(self): + """Send media_play command to player.""" + response = self.client.resume() + self.update_state(response) + + def media_pause(self): + """Send media_pause command to player.""" + response = self.client.pause() + self.update_state(response) + + def media_next_track(self): + """Seek ahead.""" + response = self.client.skip_forward() + self.update_state(response) + + def media_previous_track(self): + """Seek back.""" + response = self.client.skip_backward() + self.update_state(response) + + def select_source(self, source): + """Select a channel to tune to.""" + for channel in self.favorite_channels: + if channel["name"] == source: + response = self.client.play_channel(channel["number"]) + self.update_state(response) + break + + def play_media(self, media_type, media_id, **kwargs): + """Send the play_media command to the player.""" + if media_type == MEDIA_TYPE_CHANNEL: + response = self.client.play_channel(media_id) + self.update_state(response) + elif media_type in [MEDIA_TYPE_VIDEO, MEDIA_TYPE_EPISODE, + MEDIA_TYPE_TVSHOW]: + response = self.client.play_recording(media_id) + self.update_state(response) + + def seek_forward(self): + """Seek forward in the timeline.""" + response = self.client.seek_forward() + self.update_state(response) + + def seek_backward(self): + """Seek backward in the timeline.""" + response = self.client.seek_backward() + self.update_state(response) + + def seek_by(self, seconds): + """Seek backward in the timeline.""" + response = self.client.seek(seconds) + self.update_state(response) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 9e4e912f314..2be7ad431cf 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -37,11 +37,13 @@ YOUTUBE_PLAYER_SUPPORT = \ MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ - SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK class AbstractDemoPlayer(MediaPlayerDevice): @@ -284,15 +286,7 @@ class DemoMusicPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = MUSIC_PLAYER_SUPPORT - - if self._cur_track > 0: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_track < len(self.tracks) - 1: - support |= SUPPORT_NEXT_TRACK - - return support + return MUSIC_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" @@ -379,15 +373,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): @property def supported_features(self): """Flag media player features that are supported.""" - support = NETFLIX_PLAYER_SUPPORT - - if self._cur_episode > 1: - support |= SUPPORT_PREVIOUS_TRACK - - if self._cur_episode < self._episode_count: - support |= SUPPORT_NEXT_TRACK - - return support + return NETFLIX_PLAYER_SUPPORT def media_previous_track(self): """Send previous track command.""" diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index e363ab12f92..7b5658c56d9 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -21,7 +21,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pyemby==1.4'] +REQUIREMENTS = ['pyemby==1.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index bca6f2ad770..5e30f9783c7 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -156,7 +156,7 @@ class OpenhomeDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._device.Uuid() @property diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index a63bf8525ed..48e532074f7 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_utc_time_change from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -154,11 +154,14 @@ def setup_plexserver( return new_plex_clients = [] + available_client_ids = [] for device in devices: # For now, let's allow all deviceClass types if device.deviceClass in ['badClient']: continue + available_client_ids.append(device.machineIdentifier) + if device.machineIdentifier not in plex_clients: new_client = PlexClient(config, device, None, plex_sessions, update_devices, @@ -186,6 +189,9 @@ def setup_plexserver( if client.session is None: client.force_idle() + client.set_availability(client.machine_identifier + in available_client_ids) + if new_plex_clients: add_devices_callback(new_plex_clients) @@ -259,6 +265,7 @@ class PlexClient(MediaPlayerDevice): """Initialize the Plex device.""" self._app_name = '' self._device = None + self._available = False self._device_protocol_capabilities = None self._is_player_active = False self._is_player_available = False @@ -407,6 +414,12 @@ class PlexClient(MediaPlayerDevice): self._media_image_url = thumb_url + def set_availability(self, available): + """Set the device as available/unavailable noting time.""" + if not available: + self._clear_media_details() + self._available = available + def _set_player_state(self): if self._player_state == 'playing': self._is_player_active = True @@ -468,6 +481,11 @@ class PlexClient(MediaPlayerDevice): """Return the id of this plex client.""" return self.machine_identifier + @property + def available(self): + """Return the availability of the client.""" + return self._available + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 7ac250b1d30..beaea8a8ad0 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -242,6 +242,30 @@ sonos_set_option: description: Enable Speech Enhancement mode example: 'true' +channels_seek_forward: + description: Seek forward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + +channels_seek_backward: + description: Seek backward by a set number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + +channels_seek_by: + description: Seek by an inputted number of seconds. + fields: + entity_id: + description: Name of entity for the instance of Channels to seek in. + example: 'media_player.family_room_channels' + seconds: + description: Number of seconds to seek by. Negative numbers seek backwards. + example: 120 + soundtouch_play_everywhere: description: Play on all Bose Soundtouch devices. fields: @@ -364,3 +388,17 @@ bluesound_clear_sleep_timer: entity_id: description: Name(s) of entities that will have the timer cleared. example: 'media_player.bluesound_livingroom' + +songpal_set_sound_setting: + description: Change sound setting. + + fields: + entity_id: + description: Target device. + example: 'media_player.my_soundbar' + name: + description: Name of the setting. + example: 'nightMode' + value: + description: Value to set. + example: 'on' diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py new file mode 100644 index 00000000000..b1dc7df3319 --- /dev/null +++ b/homeassistant/components/media_player/songpal.py @@ -0,0 +1,252 @@ +""" +Support for Songpal-enabled (Sony) media devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.songpal/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_SET, + SUPPORT_TURN_ON, MediaPlayerDevice, DOMAIN) +from homeassistant.const import ( + CONF_NAME, STATE_ON, STATE_OFF, ATTR_ENTITY_ID) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['python-songpal==0.0.6'] + +SUPPORT_SONGPAL = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM = "songpal" + +SET_SOUND_SETTING = "songpal_set_sound_setting" + +PARAM_NAME = "name" +PARAM_VALUE = "value" + +CONF_ENDPOINT = "endpoint" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_ENDPOINT): cv.string, +}) + +SET_SOUND_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(PARAM_NAME): cv.string, + vol.Required(PARAM_VALUE): cv.string}) + + +async def async_setup_platform(hass, config, + async_add_devices, discovery_info=None): + """Set up the Songpal platform.""" + from songpal import SongpalException + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {} + + if discovery_info is not None: + name = discovery_info["name"] + endpoint = discovery_info["properties"]["endpoint"] + _LOGGER.debug("Got autodiscovered %s - endpoint: %s", name, endpoint) + + device = SongpalDevice(name, endpoint) + else: + name = config.get(CONF_NAME) + endpoint = config.get(CONF_ENDPOINT) + device = SongpalDevice(name, endpoint) + + try: + await device.initialize() + except SongpalException as ex: + _LOGGER.error("Unable to get methods from songpal: %s", ex) + raise PlatformNotReady + + hass.data[PLATFORM][endpoint] = device + + async_add_devices([device], True) + + async def async_service_handler(service): + """Service handler.""" + entity_id = service.data.get("entity_id", None) + params = {key: value for key, value in service.data.items() + if key != ATTR_ENTITY_ID} + + for device in hass.data[PLATFORM].values(): + if device.entity_id == entity_id or entity_id is None: + _LOGGER.debug("Calling %s (entity: %s) with params %s", + service, entity_id, params) + + await device.async_set_sound_setting(params[PARAM_NAME], + params[PARAM_VALUE]) + + hass.services.async_register( + DOMAIN, SET_SOUND_SETTING, async_service_handler, + schema=SET_SOUND_SCHEMA) + + +class SongpalDevice(MediaPlayerDevice): + """Class representing a Songpal device.""" + + def __init__(self, name, endpoint): + """Init.""" + import songpal + self._name = name + self.endpoint = endpoint + self.dev = songpal.Protocol(self.endpoint) + self._sysinfo = None + + self._state = False + self._available = False + self._initialized = False + + self._volume_control = None + self._volume_min = 0 + self._volume_max = 1 + self._volume = 0 + self._is_muted = False + + self._sources = [] + + async def initialize(self): + """Initialize the device.""" + await self.dev.get_supported_methods() + self._sysinfo = await self.dev.get_system_info() + + @property + def name(self): + """Return name of the device.""" + return self._name + + @property + def unique_id(self): + """Return a unique ID.""" + return self._sysinfo.macAddr + + @property + def available(self): + """Return availability of the device.""" + return self._available + + async def async_set_sound_setting(self, name, value): + """Change a setting on the device.""" + await self.dev.set_sound_settings(name, value) + + async def async_update(self): + """Fetch updates from the device.""" + from songpal import SongpalException + try: + volumes = await self.dev.get_volume_information() + if not volumes: + _LOGGER.error("Got no volume controls, bailing out") + self._available = False + return + + if len(volumes) > 1: + _LOGGER.warning("Got %s volume controls, using the first one", + volumes) + + volume = volumes.pop() + _LOGGER.debug("Current volume: %s", volume) + + self._volume_max = volume.maxVolume + self._volume_min = volume.minVolume + self._volume = volume.volume + self._volume_control = volume + self._is_muted = self._volume_control.is_muted + + status = await self.dev.get_power() + self._state = status.status + _LOGGER.debug("Got state: %s", status) + + inputs = await self.dev.get_inputs() + _LOGGER.debug("Got ins: %s", inputs) + self._sources = inputs + + self._available = True + except SongpalException as ex: + # if we were available, print out the exception + if self._available: + _LOGGER.error("Got an exception: %s", ex) + self._available = False + + async def async_select_source(self, source): + """Select source.""" + for out in self._sources: + if out.title == source: + await out.activate() + return + + _LOGGER.error("Unable to find output: %s", source) + + @property + def source_list(self): + """Return list of available sources.""" + return [x.title for x in self._sources] + + @property + def state(self): + """Return current state.""" + if self._state: + return STATE_ON + return STATE_OFF + + @property + def source(self): + """Return currently active source.""" + for out in self._sources: + if out.active: + return out.title + + return None + + @property + def volume_level(self): + """Return volume level.""" + volume = self._volume / self._volume_max + return volume + + async def async_set_volume_level(self, volume): + """Set volume level.""" + volume = int(volume * self._volume_max) + _LOGGER.debug("Setting volume to %s", volume) + return await self._volume_control.set_volume(volume) + + async def async_volume_up(self): + """Set volume up.""" + return await self._volume_control.set_volume("+1") + + async def async_volume_down(self): + """Set volume down.""" + return await self._volume_control.set_volume("-1") + + async def async_turn_on(self): + """Turn the device on.""" + return await self.dev.set_power(True) + + async def async_turn_off(self): + """Turn the device off.""" + return await self.dev.set_power(False) + + async def async_mute_volume(self, mute): + """Mute or unmute the device.""" + _LOGGER.debug("Set mute: %s", mute) + return await self._volume_control.set_mute(mute) + + @property + def is_volume_muted(self): + """Return whether the device is muted.""" + return self._is_muted + + @property + def supported_features(self): + """Return supported features.""" + return SUPPORT_SONGPAL diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index b0a7776ec82..9ea33b4c396 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -36,8 +36,9 @@ logging.getLogger('soco.data_structures_entry').setLevel(logging.ERROR) _SOCO_SERVICES_LOGGER = logging.getLogger('soco.services') SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\ - SUPPORT_PLAY_MEDIA | SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST |\ - SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_STOP + SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE |\ + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\ + SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST SERVICE_JOIN = 'sonos_join' SERVICE_UNJOIN = 'sonos_unjoin' @@ -69,7 +70,7 @@ ATTR_SPEECH_ENHANCE = 'speech_enhance' ATTR_IS_COORDINATOR = 'is_coordinator' -UPNP_ERRORS_TO_IGNORE = ['701'] +UPNP_ERRORS_TO_IGNORE = ['701', '711'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ADVERTISE_ADDR): cv.string, @@ -113,6 +114,7 @@ class SonosData: def __init__(self): """Initialize the data.""" + self.uids = set() self.devices = [] self.topology_lock = threading.Lock() @@ -129,7 +131,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Avoid SoCo 0.14 event thread dying from invalid xml.""" try: return orig_parse_event_xml(xml) - except soco.exceptions.SoCoException: + # pylint: disable=broad-except + except Exception as ex: + _LOGGER.debug("Dodged exception: %s %s", type(ex), str(ex)) return {} soco.events.parse_event_xml = safe_parse_event_xml @@ -145,15 +149,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): player = soco.SoCo(discovery_info.get('host')) # If device already exists by config - if player.uid in [x.unique_id for x in hass.data[DATA_SONOS].devices]: + if player.uid in hass.data[DATA_SONOS].uids: return if player.is_visible: - device = SonosDevice(player) - hass.data[DATA_SONOS].devices.append(device) - add_devices([device]) - if len(hass.data[DATA_SONOS].devices) > 1: - return + hass.data[DATA_SONOS].uids.add(player.uid) + add_devices([SonosDevice(player)]) else: players = None hosts = config.get(CONF_HOSTS, None) @@ -177,19 +178,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.warning("No Sonos speakers found") return - hass.data[DATA_SONOS].devices = [SonosDevice(p) for p in players] - add_devices(hass.data[DATA_SONOS].devices) + hass.data[DATA_SONOS].uids.update([p.uid for p in players]) + add_devices([SonosDevice(p) for p in players]) _LOGGER.debug("Added %s Sonos speakers", len(players)) def service_handle(service): """Handle for services.""" entity_ids = service.data.get('entity_id') + devices = hass.data[DATA_SONOS].devices if entity_ids: - devices = [device for device in hass.data[DATA_SONOS].devices - if device.entity_id in entity_ids] - else: - devices = hass.data[DATA_SONOS].devices + devices = [d for d in devices if d.entity_id in entity_ids] if service.service == SERVICE_JOIN: master = [device for device in hass.data[DATA_SONOS].devices @@ -321,8 +320,10 @@ def _timespan_secs(timespan): def _is_radio_uri(uri): """Return whether the URI is a radio stream.""" - return uri.startswith('x-rincon-mp3radio:') or \ - uri.startswith('x-sonosapi-stream:') + radio_schemes = ( + 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:', + 'hls-radio:') + return uri.startswith(radio_schemes) class SonosDevice(MediaPlayerDevice): @@ -340,7 +341,6 @@ class SonosDevice(MediaPlayerDevice): self._name = None self._coordinator = None self._status = None - self._extra_features = 0 self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -361,11 +361,12 @@ class SonosDevice(MediaPlayerDevice): @asyncio.coroutine def async_added_to_hass(self): """Subscribe sonos events.""" + self.hass.data[DATA_SONOS].devices.append(self) self.hass.async_add_job(self._subscribe_to_player_events) @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property @@ -431,6 +432,10 @@ class SonosDevice(MediaPlayerDevice): """Add event subscriptions.""" player = self.soco + # New player available, build the current group topology + for device in self.hass.data[DATA_SONOS].devices: + device.process_zonegrouptopology_event(None) + queue = _ProcessSonosEventQueue(self.process_avtransport_event) player.avTransport.subscribe(auto_renew=True, event_queue=queue) @@ -460,7 +465,6 @@ class SonosDevice(MediaPlayerDevice): self._media_artist = None self._media_album_name = None self._media_title = None - self._extra_features = 0 self._source_name = None def process_avtransport_event(self, event): @@ -481,15 +485,11 @@ class SonosDevice(MediaPlayerDevice): else: track_info = self.soco.get_current_track_info() - media_info = self.soco.avTransport.GetMediaInfo( - [('InstanceID', 0)] - ) - if _is_radio_uri(track_info['uri']): - self._refresh_radio(event.variables, media_info, track_info) + self._refresh_radio(event.variables, track_info) else: update_position = (new_status != self._status) - self._refresh_music(update_position, media_info, track_info) + self._refresh_music(update_position, track_info) self._status = new_status @@ -521,11 +521,11 @@ class SonosDevice(MediaPlayerDevice): def process_zonegrouptopology_event(self, event): """Process a zone group topology event coming from a player.""" - if not hasattr(event, 'zone_player_uui_ds_in_group'): + if event and not hasattr(event, 'zone_player_uui_ds_in_group'): return with self.hass.data[DATA_SONOS].topology_lock: - group = event.zone_player_uui_ds_in_group + group = event and event.zone_player_uui_ds_in_group if group: # New group information is pushed coordinator_uid, *slave_uids = group.split(',') @@ -561,8 +561,6 @@ class SonosDevice(MediaPlayerDevice): def _refresh_linein(self, source): """Update state when playing from line-in/tv.""" - self._extra_features = 0 - self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -575,14 +573,13 @@ class SonosDevice(MediaPlayerDevice): self._source_name = source - def _refresh_radio(self, variables, media_info, track_info): + def _refresh_radio(self, variables, track_info): """Update state when streaming radio.""" - self._extra_features = 0 - self._media_duration = None self._media_position = None self._media_position_updated_at = None + media_info = self.soco.avTransport.GetMediaInfo([('InstanceID', 0)]) self._media_image_url = self._radio_artwork(media_info['CurrentURI']) self._media_artist = track_info.get('artist') @@ -637,30 +634,8 @@ class SonosDevice(MediaPlayerDevice): if fav.reference.get_uri() == media_info['CurrentURI']: self._source_name = fav.title - def _refresh_music(self, update_media_position, media_info, track_info): + def _refresh_music(self, update_media_position, track_info): """Update state when playing music tracks.""" - self._extra_features = SUPPORT_PAUSE | SUPPORT_SHUFFLE_SET |\ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK - - playlist_position = track_info.get('playlist_position') - if playlist_position in ('', 'NOT_IMPLEMENTED', None): - playlist_position = None - else: - playlist_position = int(playlist_position) - - playlist_size = media_info.get('NrTracks') - if playlist_size in ('', 'NOT_IMPLEMENTED', None): - playlist_size = None - else: - playlist_size = int(playlist_size) - - if playlist_position is not None and playlist_size is not None: - if playlist_position <= 1: - self._extra_features &= ~SUPPORT_PREVIOUS_TRACK - - if playlist_position == playlist_size: - self._extra_features &= ~SUPPORT_NEXT_TRACK - self._media_duration = _timespan_secs(track_info.get('duration')) position_info = self.soco.avTransport.GetPositionInfo( @@ -771,7 +746,7 @@ class SonosDevice(MediaPlayerDevice): @soco_coordinator def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_SONOS | self._extra_features + return SUPPORT_SONOS @soco_error() def volume_up(self): @@ -861,19 +836,19 @@ class SonosDevice(MediaPlayerDevice): """Send pause command.""" self.soco.pause() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_next_track(self): """Send next track command.""" self.soco.next() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_previous_track(self): """Send next track command.""" self.soco.previous() - @soco_error() + @soco_error(UPNP_ERRORS_TO_IGNORE) @soco_coordinator def media_seek(self, position): """Send seek command.""" diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 1fd61b3ead1..86b4087ca81 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -230,7 +230,7 @@ class SqueezeBoxDevice(MediaPlayerDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._id @property diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 8a5fdb5b86b..27e7c0358ad 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,9 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio -from collections import namedtuple from itertools import groupby -from typing import Optional +from typing import Optional, Any, Union, Callable, List, cast # noqa: F401 from operator import attrgetter import logging import os @@ -16,15 +15,17 @@ import time import ssl import re import requests.certs +import attr import voluptuous as vol -from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.core import callback +from homeassistant.helpers.typing import HomeAssistantType, ConfigType, \ + ServiceDataType +from homeassistant.core import callback, Event, ServiceCall from homeassistant.setup import async_prepare_setup_platform from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass -from homeassistant.helpers import template, ConfigType, config_validation as cv +from homeassistant.helpers import template, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util.async import ( run_coroutine_threadsafe, run_callback_threadsafe) @@ -89,7 +90,7 @@ ATTR_RETAIN = CONF_RETAIN MAX_RECONNECT_WAIT = 300 # seconds -def valid_subscribe_topic(value, invalid_chars='\0'): +def valid_subscribe_topic(value: Any, invalid_chars='\0') -> str: """Validate that we can subscribe using this MQTT topic.""" value = cv.string(value) if all(c not in value for c in invalid_chars): @@ -97,12 +98,12 @@ def valid_subscribe_topic(value, invalid_chars='\0'): raise vol.Invalid('Invalid MQTT topic name') -def valid_publish_topic(value): +def valid_publish_topic(value: Any) -> str: """Validate that we can publish using this MQTT topic.""" return valid_subscribe_topic(value, invalid_chars='#+\0') -def valid_discovery_topic(value): +def valid_discovery_topic(value: Any) -> str: """Validate a discovery topic.""" return valid_subscribe_topic(value, invalid_chars='#+\0/') @@ -185,7 +186,13 @@ MQTT_PUBLISH_SCHEMA = vol.Schema({ }, required=True) -def _build_publish_data(topic, qos, retain): +# pylint: disable=invalid-name +PublishPayloadType = Union[str, bytes, int, float, None] +SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None +MessageCallbackType = Callable[[str, SubscribePayloadType, int], None] + + +def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: """Build the arguments for the publish service without the payload.""" data = {ATTR_TOPIC: topic} if qos is not None: @@ -196,14 +203,16 @@ def _build_publish_data(topic, qos, retain): @bind_hass -def publish(hass, topic, payload, qos=None, retain=None): +def publish(hass: HomeAssistantType, topic, payload, qos=None, + retain=None) -> 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): +def async_publish(hass: HomeAssistantType, topic: Any, payload, qos=None, + retain=None) -> None: """Publish message to an MQTT topic.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD] = payload @@ -211,30 +220,32 @@ def async_publish(hass, topic, payload, qos=None, retain=None): @bind_hass -def publish_template(hass, topic, payload_template, qos=None, retain=None): +def publish_template(hass: HomeAssistantType, topic, payload_template, + qos=None, retain=None) -> None: """Publish message to an MQTT topic using a template payload.""" data = _build_publish_data(topic, qos, retain) data[ATTR_PAYLOAD_TEMPLATE] = payload_template hass.services.call(DOMAIN, SERVICE_PUBLISH, data) -@asyncio.coroutine @bind_hass -def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): +async def async_subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str = 'utf-8'): """Subscribe to an MQTT topic. Call the return value to unsubscribe. """ - async_remove = \ - yield from hass.data[DATA_MQTT].async_subscribe(topic, msg_callback, - qos, encoding) + async_remove = await hass.data[DATA_MQTT].async_subscribe( + topic, msg_callback, qos, encoding) return async_remove @bind_hass -def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, - encoding='utf-8'): +def subscribe(hass: HomeAssistantType, topic: str, + msg_callback: MessageCallbackType, qos: int = DEFAULT_QOS, + encoding: str = 'utf-8') -> Callable[[], None]: """Subscribe to an MQTT topic.""" async_remove = run_coroutine_threadsafe( async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop @@ -247,15 +258,15 @@ def subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS, return remove -@asyncio.coroutine -def _async_setup_server(hass, config): +async def _async_setup_server(hass: HomeAssistantType, + config: ConfigType): """Try to start embedded MQTT broker. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType - server = yield from async_prepare_setup_platform( + server = await async_prepare_setup_platform( hass, config, DOMAIN, 'server') if server is None: @@ -263,60 +274,62 @@ def _async_setup_server(hass, config): return None success, broker_config = \ - yield from server.async_start(hass, conf.get(CONF_EMBEDDED)) + await server.async_start(hass, conf.get(CONF_EMBEDDED)) - return success and broker_config + if not success: + return None + return broker_config -@asyncio.coroutine -def _async_setup_discovery(hass, config): +async def _async_setup_discovery(hass: HomeAssistantType, + config: ConfigType) -> bool: """Try to start the discovery of MQTT devices. This method is a coroutine. """ - conf = config.get(DOMAIN, {}) + conf = config.get(DOMAIN, {}) # type: ConfigType - discovery = yield from async_prepare_setup_platform( + discovery = await async_prepare_setup_platform( hass, config, DOMAIN, 'discovery') if discovery is None: _LOGGER.error("Unable to load MQTT discovery") - return None + return False - success = yield from discovery.async_start( - hass, conf[CONF_DISCOVERY_PREFIX], config) + success = await discovery.async_start( + hass, conf[CONF_DISCOVERY_PREFIX], config) # type: bool return success -@asyncio.coroutine -def async_setup(hass: HomeAssistantType, config: ConfigType): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Start the MQTT protocol service.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN) # type: Optional[ConfigType] if conf is None: conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + conf = cast(ConfigType, conf) - client_id = conf.get(CONF_CLIENT_ID) - keepalive = conf.get(CONF_KEEPALIVE) + client_id = conf.get(CONF_CLIENT_ID) # type: Optional[str] + keepalive = conf.get(CONF_KEEPALIVE) # type: int # Only setup if embedded config passed in or no broker specified if CONF_EMBEDDED not in conf and CONF_BROKER in conf: broker_config = None else: - broker_config = yield from _async_setup_server(hass, config) + broker_config = await _async_setup_server(hass, config) if CONF_BROKER in conf: - broker = conf[CONF_BROKER] - port = conf[CONF_PORT] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - certificate = conf.get(CONF_CERTIFICATE) - client_key = conf.get(CONF_CLIENT_KEY) - client_cert = conf.get(CONF_CLIENT_CERT) - tls_insecure = conf.get(CONF_TLS_INSECURE) - protocol = conf[CONF_PROTOCOL] - elif broker_config: + broker = conf[CONF_BROKER] # type: str + port = conf[CONF_PORT] # type: int + username = conf.get(CONF_USERNAME) # type: Optional[str] + password = conf.get(CONF_PASSWORD) # type: Optional[str] + certificate = conf.get(CONF_CERTIFICATE) # type: Optional[str] + client_key = conf.get(CONF_CLIENT_KEY) # type: Optional[str] + client_cert = conf.get(CONF_CLIENT_CERT) # type: Optional[str] + tls_insecure = conf.get(CONF_TLS_INSECURE) # type: Optional[bool] + protocol = conf[CONF_PROTOCOL] # type: str + elif broker_config is not None: # If no broker passed in, auto config to internal server broker, port, username, password, certificate, protocol = broker_config # Embedded broker doesn't have some ssl variables @@ -342,15 +355,15 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): if certificate == 'auto': certificate = requests.certs.where() - will_message = None + will_message = None # type: Optional[Message] if conf.get(CONF_WILL_MESSAGE) is not None: will_message = Message(**conf.get(CONF_WILL_MESSAGE)) - birth_message = None + birth_message = None # type: Optional[Message] if conf.get(CONF_BIRTH_MESSAGE) is not None: birth_message = Message(**conf.get(CONF_BIRTH_MESSAGE)) # Be able to override versions other than TLSv1.0 under Python3.6 - conf_tls_version = conf.get(CONF_TLS_VERSION) + conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str if conf_tls_version == '1.2': tls_version = ssl.PROTOCOL_TLSv1_2 elif conf_tls_version == '1.1': @@ -375,25 +388,23 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): "Please check your settings and the broker itself") return False - @asyncio.coroutine - def async_stop_mqtt(event): + async def async_stop_mqtt(event: Event): """Stop MQTT component.""" - yield from hass.data[DATA_MQTT].async_disconnect() + await hass.data[DATA_MQTT].async_disconnect() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) - success = yield from hass.data[DATA_MQTT].async_connect() + success = await hass.data[DATA_MQTT].async_connect() # type: bool if not success: return False - @asyncio.coroutine - def async_publish_service(call): + async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" - msg_topic = call.data[ATTR_TOPIC] + msg_topic = call.data[ATTR_TOPIC] # type: str payload = call.data.get(ATTR_PAYLOAD) payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE) - qos = call.data[ATTR_QOS] - retain = call.data[ATTR_RETAIN] + qos = call.data[ATTR_QOS] # type: int + retain = call.data[ATTR_RETAIN] # type: bool if payload_template is not None: try: payload = \ @@ -405,7 +416,7 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): msg_topic, payload_template, exc) return - yield from hass.data[DATA_MQTT].async_publish( + await hass.data[DATA_MQTT].async_publish( msg_topic, payload, qos, retain) hass.services.async_register( @@ -413,26 +424,41 @@ def async_setup(hass: HomeAssistantType, config: ConfigType): schema=MQTT_PUBLISH_SCHEMA) if conf.get(CONF_DISCOVERY): - yield from _async_setup_discovery(hass, config) + await _async_setup_discovery(hass, config) return True -Subscription = namedtuple('Subscription', - ['topic', 'callback', 'qos', 'encoding']) -Subscription.__new__.__defaults__ = (0, 'utf-8') +@attr.s(slots=True, frozen=True) +class Subscription(object): + """Class to hold data about an active subscription.""" -Message = namedtuple('Message', ['topic', 'payload', 'qos', 'retain']) -Message.__new__.__defaults__ = (0, False) + topic = attr.ib(type=str) + callback = attr.ib(type=MessageCallbackType) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default='utf-8') + + +@attr.s(slots=True, frozen=True) +class Message(object): + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int, default=0) + retain = attr.ib(type=bool, default=False) class MQTT(object): """Home Assistant MQTT client.""" - def __init__(self, hass, broker, port, client_id, keepalive, username, - password, certificate, client_key, client_cert, - tls_insecure, protocol, will_message: Optional[Message], - birth_message: Optional[Message], tls_version): + def __init__(self, hass: HomeAssistantType, broker: str, port: int, + client_id: Optional[str], keepalive: Optional[int], + username: Optional[str], password: Optional[str], + certificate: Optional[str], client_key: Optional[str], + client_cert: Optional[str], tls_insecure: Optional[bool], + protocol: Optional[str], will_message: Optional[Message], + birth_message: Optional[Message], tls_version) -> None: """Initialize Home Assistant MQTT client.""" import paho.mqtt.client as mqtt @@ -440,13 +466,13 @@ class MQTT(object): self.broker = broker self.port = port self.keepalive = keepalive - self.subscriptions = [] + self.subscriptions = [] # type: List[Subscription] self.birth_message = birth_message - self._mqttc = None + self._mqttc = None # type: mqtt.Client self._paho_lock = asyncio.Lock(loop=hass.loop) if protocol == PROTOCOL_31: - proto = mqtt.MQTTv31 + proto = mqtt.MQTTv31 # type: int else: proto = mqtt.MQTTv311 @@ -470,26 +496,26 @@ class MQTT(object): self._mqttc.on_disconnect = self._mqtt_on_disconnect self._mqttc.on_message = self._mqtt_on_message - if will_message: - self._mqttc.will_set(*will_message) + if will_message is not None: + self._mqttc.will_set(*attr.astuple(will_message)) - @asyncio.coroutine - def async_publish(self, topic, payload, qos, retain): + async def async_publish(self, topic: str, payload: PublishPayloadType, + qos: int, retain: bool) -> None: """Publish a MQTT message. This method must be run in the event loop and returns a coroutine. """ - with (yield from self._paho_lock): - yield from self.hass.async_add_job( + async with self._paho_lock: + await self.hass.async_add_job( self._mqttc.publish, topic, payload, qos, retain) - @asyncio.coroutine - def async_connect(self): + async def async_connect(self) -> bool: """Connect to the host. Does process messages yet. This method is a coroutine. """ - result = yield from self.hass.async_add_job( + result = None # type: int + result = await self.hass.async_add_job( self._mqttc.connect, self.broker, self.port, self.keepalive) if result != 0: @@ -500,6 +526,7 @@ class MQTT(object): return not result + @callback def async_disconnect(self): """Stop the MQTT client. @@ -512,8 +539,9 @@ class MQTT(object): return self.hass.async_add_job(stop) - @asyncio.coroutine - def async_subscribe(self, topic, msg_callback, qos, encoding): + async def async_subscribe(self, topic: str, + msg_callback: MessageCallbackType, + qos: int, encoding: str) -> Callable[[], None]: """Set up a subscription to a topic with the provided qos. This method is a coroutine. @@ -524,10 +552,10 @@ class MQTT(object): subscription = Subscription(topic, msg_callback, qos, encoding) self.subscriptions.append(subscription) - yield from self._async_perform_subscription(topic, qos) + await self._async_perform_subscription(topic, qos) @callback - def async_remove(): + def async_remove() -> None: """Remove subscription.""" if subscription not in self.subscriptions: raise HomeAssistantError("Can't remove subscription twice") @@ -540,28 +568,29 @@ class MQTT(object): return async_remove - @asyncio.coroutine - def _async_unsubscribe(self, topic): + async def _async_unsubscribe(self, topic: str) -> None: """Unsubscribe from a topic. This method is a coroutine. """ - with (yield from self._paho_lock): - result, _ = yield from self.hass.async_add_job( + async with self._paho_lock: + result = None # type: int + result, _ = await self.hass.async_add_job( self._mqttc.unsubscribe, topic) _raise_on_error(result) - @asyncio.coroutine - def _async_perform_subscription(self, topic, qos): + async def _async_perform_subscription(self, topic: str, qos: int) -> None: """Perform a paho-mqtt subscription.""" _LOGGER.debug("Subscribing to %s", topic) - with (yield from self._paho_lock): - result, _ = yield from self.hass.async_add_job( + async with self._paho_lock: + result = None # type: int + result, _ = await self.hass.async_add_job( self._mqttc.subscribe, topic, qos) _raise_on_error(result) - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code): + def _mqtt_on_connect(self, _mqttc, _userdata, _flags, + result_code: int) -> None: """On connect callback. Resubscribe to all topics we were subscribed to and publish birth @@ -584,21 +613,22 @@ class MQTT(object): self.hass.add_job(self._async_perform_subscription, topic, max_qos) if self.birth_message: - self.hass.add_job(self.async_publish(*self.birth_message)) + self.hass.add_job( + self.async_publish(*attr.astuple(self.birth_message))) - def _mqtt_on_message(self, _mqttc, _userdata, msg): + def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: """Message received callback.""" self.hass.add_job(self._mqtt_handle_message, msg) @callback - def _mqtt_handle_message(self, msg): + def _mqtt_handle_message(self, msg) -> None: _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) for subscription in self.subscriptions: if not _match_topic(subscription.topic, msg.topic): continue - payload = msg.payload + payload = msg.payload # type: SubscribePayloadType if subscription.encoding is not None: try: payload = msg.payload.decode(subscription.encoding) @@ -612,7 +642,7 @@ class MQTT(object): self.hass.async_run_job(subscription.callback, msg.topic, payload, msg.qos) - def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code): + def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" # When disconnected because of calling disconnect() if result_code == 0: @@ -637,18 +667,18 @@ class MQTT(object): tries += 1 -def _raise_on_error(result): +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" - if result != 0: + if result_code != 0: import paho.mqtt.client as mqtt raise HomeAssistantError( - 'Error talking to MQTT: {}'.format(mqtt.error_string(result))) + 'Error talking to MQTT: {}'.format(mqtt.error_string(result_code))) -def _match_topic(subscription, topic): +def _match_topic(subscription: str, topic: str) -> bool: """Test if topic matches subscription.""" - reg_ex_parts = [] + reg_ex_parts = [] # type: List[str] suffix = "" if subscription.endswith('#'): subscription = subscription[:-2] @@ -670,22 +700,25 @@ def _match_topic(subscription, topic): class MqttAvailability(Entity): """Mixin used for platforms that report availability.""" - def __init__(self, availability_topic, qos, payload_available, - payload_not_available): + def __init__(self, availability_topic: Optional[str], qos: Optional[int], + payload_available: Optional[str], + payload_not_available: Optional[str]) -> None: """Initialize the availability mixin.""" self._availability_topic = availability_topic self._availability_qos = qos - self._available = availability_topic is None + self._available = availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available - def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe mqtt events. This method must be run in the event loop and returns a coroutine. """ @callback - def availability_message_received(topic, payload, qos): + def availability_message_received(topic: str, + payload: SubscribePayloadType, + qos: int) -> None: """Handle a new received MQTT availability message.""" if payload == self._payload_available: self._available = True @@ -695,7 +728,7 @@ class MqttAvailability(Entity): self.async_schedule_update_ha_state() if self._availability_topic is not None: - yield from async_subscribe( + await async_subscribe( self.hass, self._availability_topic, availability_message_received, self._availability_qos) diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 45439dbfbfe..7ccf4f8db90 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -4,7 +4,6 @@ HTML5 Push Messaging notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.html5/ """ -import asyncio import datetime import json import logging @@ -27,7 +26,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.5.0', 'PyJWT==1.5.3'] +REQUIREMENTS = ['pywebpush==1.6.0', 'PyJWT==1.6.0'] DEPENDENCIES = ['frontend'] @@ -155,11 +154,10 @@ class HTML5PushRegistrationView(HomeAssistantView): self.registrations = registrations self.json_path = json_path - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations from a browser.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -177,8 +175,8 @@ class HTML5PushRegistrationView(HomeAssistantView): try: hass = request.app['hass'] - yield from hass.async_add_job(save_json, self.json_path, - self.registrations) + await hass.async_add_job(save_json, self.json_path, + self.registrations) return self.json_message( 'Push notification subscriber registered.') except HomeAssistantError: @@ -199,11 +197,10 @@ class HTML5PushRegistrationView(HomeAssistantView): return key return ensure_unique_string('unnamed device', self.registrations) - @asyncio.coroutine - def delete(self, request): + async def delete(self, request): """Delete a registration.""" try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -225,8 +222,8 @@ class HTML5PushRegistrationView(HomeAssistantView): try: hass = request.app['hass'] - yield from hass.async_add_job(save_json, self.json_path, - self.registrations) + await hass.async_add_job(save_json, self.json_path, + self.registrations) except HomeAssistantError: self.registrations[found] = reg return self.json_message( @@ -296,15 +293,14 @@ class HTML5PushCallbackView(HomeAssistantView): status_code=HTTP_UNAUTHORIZED) return payload - @asyncio.coroutine - def post(self, request): + async def post(self, request): """Accept the POST request for push registrations event callback.""" auth_check = self.check_authorization_header(request) if not isinstance(auth_check, dict): return auth_check try: - data = yield from request.json() + data = await request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) @@ -408,8 +404,14 @@ class HTML5NotificationService(BaseNotificationService): jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8') payload[ATTR_DATA][ATTR_JWT] = jwt_token + # Only pass the gcm key if we're actually using GCM + # If we don't, notifications break on FireFox + gcm_key = self._gcm_key \ + if 'googleapis.com' in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \ + else None response = WebPusher(info[ATTR_SUBSCRIPTION]).send( - json.dumps(payload), gcm_key=self._gcm_key, ttl='86400') + json.dumps(payload), gcm_key=gcm_key, ttl='86400' + ) # pylint: disable=no-member if response.status_code == 410: diff --git a/homeassistant/components/notify/knx.py b/homeassistant/components/notify/knx.py index e6bb400d421..750e3945569 100644 --- a/homeassistant/components/notify/knx.py +++ b/homeassistant/components/notify/knx.py @@ -4,7 +4,7 @@ KNX/IP notification service. For more details about this platform, please refer to the documentation https://home-assistant.io/components/notify.knx/ """ -import asyncio + import voluptuous as vol from homeassistant.components.knx import DATA_KNX, ATTR_DISCOVER_DEVICES @@ -24,8 +24,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_get_service(hass, config, discovery_info=None): +async def async_get_service(hass, config, discovery_info=None): """Get the KNX notification service.""" return async_get_service_discovery(hass, discovery_info) \ if discovery_info is not None else \ @@ -72,23 +71,20 @@ class KNXNotificationService(BaseNotificationService): ret[device.name] = device.name return ret - @asyncio.coroutine - def async_send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a notification to knx bus.""" if "target" in kwargs: - yield from self._async_send_to_device(message, kwargs["target"]) + await self._async_send_to_device(message, kwargs["target"]) else: - yield from self._async_send_to_all_devices(message) + await self._async_send_to_all_devices(message) - @asyncio.coroutine - def _async_send_to_all_devices(self, message): + async def _async_send_to_all_devices(self, message): """Send a notification to knx bus to all connected devices.""" for device in self.devices: - yield from device.set(message) + await device.set(message) - @asyncio.coroutine - def _async_send_to_device(self, message, names): + async def _async_send_to_device(self, message, names): """Send a notification to knx bus to device with given names.""" for device in self.devices: if device.name in names: - yield from device.set(message) + await device.set(message) diff --git a/homeassistant/components/notify/synology_chat.py b/homeassistant/components/notify/synology_chat.py new file mode 100644 index 00000000000..8b968729074 --- /dev/null +++ b/homeassistant/components/notify/synology_chat.py @@ -0,0 +1,53 @@ +""" +SynologyChat platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.synology_chat/ +""" +import logging +import json + +import requests +import voluptuous as vol + +from homeassistant.components.notify import ( + BaseNotificationService, PLATFORM_SCHEMA) +from homeassistant.const import CONF_RESOURCE +import homeassistant.helpers.config_validation as cv + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_RESOURCE): cv.url, +}) + +_LOGGER = logging.getLogger(__name__) + + +def get_service(hass, config, discovery_info=None): + """Get the Synology Chat notification service.""" + resource = config.get(CONF_RESOURCE) + + return SynologyChatNotificationService(resource) + + +class SynologyChatNotificationService(BaseNotificationService): + """Implementation of a notification service for Synology Chat.""" + + def __init__(self, resource): + """Initialize the service.""" + self._resource = resource + + def send_message(self, message="", **kwargs): + """Send a message to a user.""" + data = { + 'text': message + } + + to_send = 'payload={}'.format(json.dumps(data)) + + response = requests.post(self._resource, data=to_send, timeout=10) + + if response.status_code not in (200, 201): + _LOGGER.exception( + "Error sending message. Response %d: %s:", + response.status_code, response.reason) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index bffe29ec59b..392bccb56d4 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -165,7 +165,6 @@ class Recorder(threading.Thread): self.hass = hass self.keep_days = keep_days self.purge_interval = purge_interval - self.did_vacuum = False self.queue = queue.Queue() # type: Any self.recording_start = dt_util.utcnow() self.db_url = uri @@ -269,7 +268,7 @@ class Recorder(threading.Thread): def async_purge(now): """Trigger the purge and schedule the next run.""" self.queue.put( - PurgeTask(self.keep_days, repack=not self.did_vacuum)) + PurgeTask(self.keep_days, repack=False)) self.hass.helpers.event.async_track_point_in_time( async_purge, now + timedelta(days=self.purge_interval)) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 325267b857e..af70c9d998c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -143,6 +143,9 @@ def _apply_update(engine, new_version, old_version): _drop_index(engine, "states", "ix_states_entity_id_created") _create_index(engine, "states", "ix_states_entity_id_last_updated") + elif new_version == 5: + # Create supporting index for States.event_id foreign key + _create_index(engine, "states", "ix_states_event_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 7c29c8045ea..32d6291b90c 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -16,7 +16,7 @@ from homeassistant.remote import JSONEncoder # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 4 +SCHEMA_VERSION = 5 _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class States(Base): # type: ignore entity_id = Column(String(255)) state = Column(String(255)) attributes = Column(Text) - event_id = Column(Integer, ForeignKey('events.event_id')) + event_id = Column(Integer, ForeignKey('events.event_id'), index=True) last_changed = Column(DateTime(timezone=True), default=datetime.utcnow) last_updated = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index d2afb6076e3..4af2a62151f 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -66,6 +66,5 @@ def purge_old_data(instance, purge_days, repack): _LOGGER.debug("Vacuuming SQLite to free space") try: instance.engine.execute("VACUUM") - instance.did_vacuum = True except exc.OperationalError as err: _LOGGER.error("Error vacuuming SQLite: %s.", err) diff --git a/homeassistant/components/remote/apple_tv.py b/homeassistant/components/remote/apple_tv.py index 36eee4b284e..7d11c931a65 100644 --- a/homeassistant/components/remote/apple_tv.py +++ b/homeassistant/components/remote/apple_tv.py @@ -45,7 +45,7 @@ class AppleTVRemote(remote.RemoteDevice): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._atv.metadata.device_id @property diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index a44934d0a39..924556a039d 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -26,7 +26,7 @@ REQUIREMENTS = ['python-miio==0.3.7'] _LOGGER = logging.getLogger(__name__) SERVICE_LEARN = 'xiaomi_miio_learn_command' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'remote.xiaomi_miio' CONF_SLOT = 'slot' CONF_COMMANDS = 'commands' @@ -70,7 +70,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): # Create handler _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) - device = ChuangmiIr(host, token) + + # The Chuang Mi IR Remote Controller wants to be re-discovered every + # 5 minutes. As long as polling is disabled the device should be + # re-discovered (lazy_discover=False) in front of every command. + device = ChuangmiIr(host, token, lazy_discover=False) # Check that we can communicate with device. try: @@ -79,8 +83,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): _LOGGER.error("Token not accepted by device : %s", ex) return - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} friendly_name = config.get(CONF_NAME, "xiaomi_miio_" + host.replace('.', '_')) @@ -93,7 +97,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): friendly_name, device, slot, timeout, hidden, config.get(CONF_COMMANDS)) - hass.data[PLATFORM][host] = xiaomi_miio_remote + hass.data[DATA_KEY][host] = xiaomi_miio_remote async_add_devices([xiaomi_miio_remote]) @@ -106,7 +110,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): entity_id = service.data.get(ATTR_ENTITY_ID) entity = None - for remote in hass.data[PLATFORM].values(): + for remote in hass.data[DATA_KEY].values(): if remote.entity_id == entity_id: entity = remote diff --git a/homeassistant/components/rest_command.py b/homeassistant/components/rest_command.py index 026f0e9a19b..4632315b757 100644 --- a/homeassistant/components/rest_command.py +++ b/homeassistant/components/rest_command.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.const import ( CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD, - CONF_METHOD) + CONF_METHOD, CONF_HEADERS) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -38,6 +38,7 @@ COMMAND_SCHEMA = vol.Schema({ vol.Required(CONF_URL): cv.template, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, vol.Optional(CONF_PAYLOAD): cv.template, @@ -77,9 +78,14 @@ def async_setup(hass, config): template_payload.hass = hass headers = None + if CONF_HEADERS in command_config: + headers = command_config[CONF_HEADERS] + if CONF_CONTENT_TYPE in command_config: content_type = command_config[CONF_CONTENT_TYPE] - headers = {hdrs.CONTENT_TYPE: content_type} + if headers is None: + headers = {} + headers[hdrs.CONTENT_TYPE] = content_type @asyncio.coroutine def async_service_handler(service): diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index fbfe2b6959a..8f0b9d5c7ab 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -68,22 +68,20 @@ def activate(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TURN_ON, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the scenes.""" logger = logging.getLogger(__name__) component = EntityComponent(logger, DOMAIN, hass) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_scene_service(service): + async def async_handle_scene_service(service): """Handle calls to the switch services.""" target_scenes = component.async_extract_from_service(service) tasks = [scene.async_activate() for scene in target_scenes] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_ON, async_handle_scene_service, diff --git a/homeassistant/components/scene/knx.py b/homeassistant/components/scene/knx.py new file mode 100644 index 00000000000..901e25aea82 --- /dev/null +++ b/homeassistant/components/scene/knx.py @@ -0,0 +1,75 @@ +""" +Support for KNX scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.knx/ +""" +import voluptuous as vol + +from homeassistant.components.knx import ATTR_DISCOVER_DEVICES, DATA_KNX +from homeassistant.components.scene import CONF_PLATFORM, Scene +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +CONF_ADDRESS = 'address' +CONF_SCENE_NUMBER = 'scene_number' + +DEFAULT_NAME = 'KNX SCENE' +DEPENDENCIES = ['knx'] + +PLATFORM_SCHEMA = vol.Schema({ + vol.Required(CONF_PLATFORM): 'knx', + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_SCENE_NUMBER): cv.positive_int, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the scenes for KNX platform.""" + if discovery_info is not None: + async_add_devices_discovery(hass, discovery_info, async_add_devices) + else: + async_add_devices_config(hass, config, async_add_devices) + + +@callback +def async_add_devices_discovery(hass, discovery_info, async_add_devices): + """Set up scenes for KNX platform configured via xknx.yaml.""" + entities = [] + for device_name in discovery_info[ATTR_DISCOVER_DEVICES]: + device = hass.data[DATA_KNX].xknx.devices[device_name] + entities.append(KNXScene(device)) + async_add_devices(entities) + + +@callback +def async_add_devices_config(hass, config, async_add_devices): + """Set up scene for KNX platform configured within platform.""" + import xknx + scene = xknx.devices.Scene( + hass.data[DATA_KNX].xknx, + name=config.get(CONF_NAME), + group_address=config.get(CONF_ADDRESS), + scene_number=config.get(CONF_SCENE_NUMBER)) + hass.data[DATA_KNX].xknx.devices.add(scene) + async_add_devices([KNXScene(scene)]) + + +class KNXScene(Scene): + """Representation of a KNX scene.""" + + def __init__(self, scene): + """Init KNX scene.""" + self.scene = scene + + @property + def name(self): + """Return the name of the scene.""" + return self.scene.name + + async def async_activate(self): + """Activate the scene.""" + await self.scene.run() diff --git a/homeassistant/components/scene/velux.py b/homeassistant/components/scene/velux.py index 86d71153a2b..63bb23b1086 100644 --- a/homeassistant/components/scene/velux.py +++ b/homeassistant/components/scene/velux.py @@ -4,7 +4,6 @@ Support for VELUX scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.velux/ """ -import asyncio from homeassistant.components.scene import Scene from homeassistant.components.velux import _LOGGER, DATA_VELUX @@ -13,9 +12,8 @@ from homeassistant.components.velux import _LOGGER, DATA_VELUX DEPENDENCIES = ['velux'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the scenes for velux platform.""" entities = [] for scene in hass.data[DATA_VELUX].pyvlx.scenes: @@ -36,7 +34,6 @@ class VeluxScene(Scene): """Return the name of the scene.""" return self.scene.name - @asyncio.coroutine - def async_activate(self): + async def async_activate(self): """Activate the scene.""" - yield from self.scene.run() + await self.scene.run() diff --git a/homeassistant/components/sensor/.translations/season.en.json b/homeassistant/components/sensor/.translations/season.en.json new file mode 100644 index 00000000000..b42100215ca --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Autumn", + "spring": "Spring", + "summer": "Summer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 92b874cf2c8..e0bf3c86b05 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -4,7 +4,7 @@ Component to interface with various sensors that can be monitored. For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor/ """ -import asyncio + from datetime import timedelta import logging @@ -20,11 +20,10 @@ ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=30) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for sensors.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) return True diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index d67415fc65e..b4007c8d744 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -210,7 +210,7 @@ class AirQualityIndexSensor(AirVisualBaseSensor): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return 'PSI' + return 'AQI' def update(self): """Update the status of the sensor.""" diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index a13d2ca8d56..b7635f729e2 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -13,15 +13,14 @@ import async_timeout import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT from homeassistant.const import ( CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_FRIENDLY_NAME, STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, - ATTR_ID) + STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, ATTR_ID) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location, distance @@ -41,7 +40,7 @@ CONF_NETWORK = 'network' CONF_STATIONS_LIST = 'stations' DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}' -DOMAIN = 'citybikes' +PLATFORM = 'citybikes' MONITORED_NETWORKS = 'monitored-networks' @@ -132,8 +131,8 @@ def async_citybikes_request(hass, uri, schema): def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the CityBikes platform.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {MONITORED_NETWORKS: {}} + if PLATFORM not in hass.data: + hass.data[PLATFORM] = {MONITORED_NETWORKS: {}} latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -148,14 +147,14 @@ def async_setup_platform(hass, config, async_add_devices, network_id = yield from CityBikesNetwork.get_closest_network_id( hass, latitude, longitude) - if network_id not in hass.data[DOMAIN][MONITORED_NETWORKS]: + if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]: network = CityBikesNetwork(hass, network_id) - hass.data[DOMAIN][MONITORED_NETWORKS][network_id] = network + hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network hass.async_add_job(network.async_refresh) async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL) else: - network = hass.data[DOMAIN][MONITORED_NETWORKS][network_id] + network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id] yield from network.ready.wait() @@ -169,7 +168,7 @@ def async_setup_platform(hass, config, async_add_devices, if radius > dist or stations_list.intersection((station_id, station_uid)): - devices.append(CityBikesStation(network, station_id, name)) + devices.append(CityBikesStation(hass, network, station_id, name)) async_add_devices(devices, True) @@ -238,12 +237,17 @@ class CityBikesNetwork: class CityBikesStation(Entity): """CityBikes API Sensor.""" - def __init__(self, network, station_id, base_name=''): + def __init__(self, hass, network, station_id, base_name=''): """Initialize the sensor.""" self._network = network self._station_id = station_id self._station_data = {} - self._base_name = base_name + if base_name: + uid = "_".join([network.network_id, base_name, station_id]) + else: + uid = "_".join([network.network_id, station_id]) + self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, + hass=hass) @property def state(self): @@ -253,10 +257,9 @@ class CityBikesStation(Entity): @property def name(self): """Return the name of the sensor.""" - if self._base_name: - return "{} {} {}".format(self._network.network_id, self._base_name, - self._station_id) - return "{} {}".format(self._network.network_id, self._station_id) + if ATTR_NAME in self._station_data: + return self._station_data[ATTR_NAME] + return None @asyncio.coroutine def async_update(self): @@ -277,7 +280,6 @@ class CityBikesStation(Entity): ATTR_LATITUDE: self._station_data[ATTR_LATITUDE], ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE], ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS], - ATTR_FRIENDLY_NAME: self._station_data[ATTR_NAME], ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP], } return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION} diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py new file mode 100644 index 00000000000..cde50699b29 --- /dev/null +++ b/homeassistant/components/sensor/filter.py @@ -0,0 +1,299 @@ +""" +Allows the creation of a sensor that filters state property. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.filter/ +""" +import logging +import statistics +from collections import deque, Counter +from numbers import Number + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID, + ATTR_ICON, STATE_UNKNOWN, STATE_UNAVAILABLE) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.decorator import Registry +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_state_change + +_LOGGER = logging.getLogger(__name__) + +FILTER_NAME_LOWPASS = 'lowpass' +FILTER_NAME_OUTLIER = 'outlier' +FILTER_NAME_THROTTLE = 'throttle' +FILTERS = Registry() + +CONF_FILTERS = 'filters' +CONF_FILTER_NAME = 'filter' +CONF_FILTER_WINDOW_SIZE = 'window_size' +CONF_FILTER_PRECISION = 'precision' +CONF_FILTER_RADIUS = 'radius' +CONF_FILTER_TIME_CONSTANT = 'time_constant' + +DEFAULT_WINDOW_SIZE = 1 +DEFAULT_PRECISION = 2 +DEFAULT_FILTER_RADIUS = 2.0 +DEFAULT_FILTER_TIME_CONSTANT = 10 + +NAME_TEMPLATE = "{} filter" +ICON = 'mdi:chart-line-variant' + +FILTER_SCHEMA = vol.Schema({ + vol.Optional(CONF_FILTER_WINDOW_SIZE, + default=DEFAULT_WINDOW_SIZE): vol.Coerce(int), + vol.Optional(CONF_FILTER_PRECISION, + default=DEFAULT_PRECISION): vol.Coerce(int), +}) + +FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER, + vol.Optional(CONF_FILTER_RADIUS, + default=DEFAULT_FILTER_RADIUS): vol.Coerce(float), +}) + +FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS, + vol.Optional(CONF_FILTER_TIME_CONSTANT, + default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), +}) + +FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_FILTERS): vol.All(cv.ensure_list, + [vol.Any(FILTER_OUTLIER_SCHEMA, + FILTER_LOWPASS_SCHEMA, + FILTER_THROTTLE_SCHEMA)]) +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the template sensors.""" + name = config.get(CONF_NAME) + entity_id = config.get(CONF_ENTITY_ID) + + filters = [FILTERS[_filter.pop(CONF_FILTER_NAME)]( + entity=entity_id, **_filter) + for _filter in config[CONF_FILTERS]] + + async_add_devices([SensorFilter(name, entity_id, filters)]) + + +class SensorFilter(Entity): + """Representation of a Filter Sensor.""" + + def __init__(self, name, entity_id, filters): + """Initialize the sensor.""" + self._name = name + self._entity = entity_id + self._unit_of_measurement = None + self._state = None + self._filters = filters + self._icon = None + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def filter_sensor_state_listener(entity, old_state, new_state): + """Handle device state changes.""" + if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: + return + + temp_state = new_state.state + + try: + for filt in self._filters: + filtered_state = filt.filter_state(temp_state) + _LOGGER.debug("%s(%s=%s) -> %s", filt.name, + self._entity, + temp_state, + "skip" if filt.skip_processing else + filtered_state) + if filt.skip_processing: + return + temp_state = filtered_state + except ValueError: + _LOGGER.error("Could not convert state: %s to number", + self._state) + return + + self._state = temp_state + + if self._icon is None: + self._icon = new_state.attributes.get( + ATTR_ICON, ICON) + + if self._unit_of_measurement is None: + self._unit_of_measurement = new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT) + + self.async_schedule_update_ha_state() + + async_track_state_change( + self.hass, self._entity, filter_sensor_state_listener) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return self._unit_of_measurement + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_ENTITY_ID: self._entity + } + return state_attr + + +class Filter(object): + """Filter skeleton. + + Args: + window_size (int): size of the sliding window that holds previous + values + precision (int): round filtered value to precision value + entity (string): used for debugging only + """ + + def __init__(self, name, window_size=1, precision=None, entity=None): + """Initialize common attributes.""" + self.states = deque(maxlen=window_size) + self.precision = precision + self._name = name + self._entity = entity + self._skip_processing = False + + @property + def name(self): + """Return filter name.""" + return self._name + + @property + def skip_processing(self): + """Return wether the current filter_state should be skipped.""" + return self._skip_processing + + def _filter_state(self, new_state): + """Implement filter.""" + raise NotImplementedError() + + def filter_state(self, new_state): + """Implement a common interface for filters.""" + filtered = self._filter_state(new_state) + if isinstance(filtered, Number): + filtered = round(float(filtered), self.precision) + self.states.append(filtered) + return filtered + + +@FILTERS.register(FILTER_NAME_OUTLIER) +class OutlierFilter(Filter): + """BASIC outlier filter. + + Determines if new state is in a band around the median. + + Args: + radius (float): band radius + """ + + def __init__(self, window_size, precision, entity, radius): + """Initialize Filter.""" + super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity) + self._radius = radius + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the outlier filter.""" + new_state = float(new_state) + + if (self.states and + abs(new_state - statistics.median(self.states)) + > self._radius): + + self._stats_internal['erasures'] += 1 + + _LOGGER.debug("Outlier nr. %s in %s: %s", + self._stats_internal['erasures'], + self._entity, new_state) + return self.states[-1] + return new_state + + +@FILTERS.register(FILTER_NAME_LOWPASS) +class LowPassFilter(Filter): + """BASIC Low Pass Filter. + + Args: + time_constant (int): time constant. + """ + + def __init__(self, window_size, precision, entity, time_constant): + """Initialize Filter.""" + super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity) + self._time_constant = time_constant + + def _filter_state(self, new_state): + """Implement the low pass filter.""" + new_state = float(new_state) + + if not self.states: + return new_state + + new_weight = 1.0 / self._time_constant + prev_weight = 1.0 - new_weight + filtered = prev_weight * self.states[-1] + new_weight * new_state + + return filtered + + +@FILTERS.register(FILTER_NAME_THROTTLE) +class ThrottleFilter(Filter): + """Throttle Filter. + + One sample per window. + """ + + def __init__(self, window_size, precision, entity): + """Initialize Filter.""" + super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + + def _filter_state(self, new_state): + """Implement the throttle filter.""" + if not self.states or len(self.states) == self.states.maxlen: + self.states.clear() + self._skip_processing = False + else: + self._skip_processing = True + + return new_state diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 35748b30ecf..8d64a8d8229 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -15,6 +15,7 @@ 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.const import CONF_UNIT_SYSTEM from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level import homeassistant.helpers.config_validation as cv @@ -144,7 +145,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES): vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]), vol.Optional(CONF_CLOCK_FORMAT, default='24H'): - vol.In(['12H', '24H']) + vol.In(['12H', '24H']), + vol.Optional(CONF_UNIT_SYSTEM, default='default'): + vol.In(['en_GB', 'en_US', 'metric', 'default']) }) @@ -248,12 +251,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if int(time.time()) - expires_at > 3600: authd_client.client.refresh_token() - authd_client.system = authd_client.user_profile_get()["user"]["locale"] - if authd_client.system != 'en_GB': - if hass.config.units.is_metric: - authd_client.system = 'metric' - else: - authd_client.system = 'en_US' + unit_system = config.get(CONF_UNIT_SYSTEM) + if unit_system == 'default': + authd_client.system = authd_client. \ + user_profile_get()["user"]["locale"] + if authd_client.system != 'en_GB': + if hass.config.units.is_metric: + authd_client.system = 'metric' + else: + authd_client.system = 'en_US' + else: + authd_client.system = unit_system dev = [] registered_devs = authd_client.get_devices() @@ -463,7 +471,8 @@ class FitbitSensor(Entity): hours -= 12 elif hours == 0: hours = 12 - self._state = '{}:{} {}'.format(hours, minutes, setting) + self._state = '{}:{:02d} {}'.format(hours, minutes, + setting) else: self._state = raw_state else: diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py index bd3957a36ca..a185cd1e825 100644 --- a/homeassistant/components/sensor/folder.py +++ b/homeassistant/components/sensor/folder.py @@ -21,7 +21,7 @@ CONF_FOLDER_PATHS = 'folder' CONF_FILTER = 'filter' DEFAULT_FILTER = '*' -SCAN_INTERVAL = timedelta(seconds=1) +SCAN_INTERVAL = timedelta(minutes=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_FOLDER_PATHS): cv.isdir, diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index de7b7ebaf9e..7af858b9d94 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -106,8 +106,8 @@ class HistoryStatsSensor(Entity): self._unit_of_measurement = UNITS[sensor_type] self._period = (datetime.datetime.now(), datetime.datetime.now()) - self.value = 0 - self.count = 0 + self.value = None + self.count = None def force_refresh(*args): """Force the component to refresh.""" @@ -127,6 +127,9 @@ class HistoryStatsSensor(Entity): @property def state(self): """Return the state of the sensor.""" + if self.value is None or self.count is None: + return None + if self._type == CONF_TYPE_TIME: return round(self.value, 2) @@ -149,6 +152,9 @@ class HistoryStatsSensor(Entity): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + if self.value is None: + return {} + hsh = HistoryStatsHelper return { ATTR_VALUE: hsh.pretty_duration(self.value), diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index f0585228851..647a40295ac 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -39,8 +39,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): """Set up the IMAP platform.""" sensor = ImapSensor(config.get(CONF_NAME), config.get(CONF_USERNAME), @@ -48,8 +50,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_SERVER), config.get(CONF_PORT), config.get(CONF_FOLDER)) - - if not (yield from sensor.connection()): + if not await sensor.connection(): raise PlatformNotReady hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown()) @@ -72,8 +73,7 @@ class ImapSensor(Entity): self._does_push = None self._idle_loop_task = None - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Handle when an entity is about to be added to Home Assistant.""" if not self.should_poll: self._idle_loop_task = self.hass.loop.create_task(self.idle_loop()) @@ -103,8 +103,7 @@ class ImapSensor(Entity): """Return if polling is needed.""" return not self._does_push - @asyncio.coroutine - def connection(self): + async def connection(self): """Return a connection to the server, establishing it if necessary.""" import aioimaplib @@ -112,53 +111,50 @@ class ImapSensor(Entity): try: self._connection = aioimaplib.IMAP4_SSL( self._server, self._port) - yield from self._connection.wait_hello_from_server() - yield from self._connection.login(self._user, self._password) - yield from self._connection.select(self._folder) + await self._connection.wait_hello_from_server() + await self._connection.login(self._user, self._password) + await self._connection.select(self._folder) self._does_push = self._connection.has_capability('IDLE') except (aioimaplib.AioImapException, asyncio.TimeoutError): self._connection = None return self._connection - @asyncio.coroutine - def idle_loop(self): + async def idle_loop(self): """Wait for data pushed from server.""" import aioimaplib while True: try: - if (yield from self.connection()): - yield from self.refresh_unread_count() - yield from self.async_update_ha_state() + if await self.connection(): + await self.refresh_unread_count() + await self.async_update_ha_state() - idle = yield from self._connection.idle_start() - yield from self._connection.wait_server_push() + idle = await self._connection.idle_start() + await self._connection.wait_server_push() self._connection.idle_done() with async_timeout.timeout(10): - yield from idle + await idle else: - yield from self.async_update_ha_state() + await self.async_update_ha_state() except (aioimaplib.AioImapException, asyncio.TimeoutError): self.disconnected() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Periodic polling of state.""" import aioimaplib try: - if (yield from self.connection()): - yield from self.refresh_unread_count() + if await self.connection(): + await self.refresh_unread_count() except (aioimaplib.AioImapException, asyncio.TimeoutError): self.disconnected() - @asyncio.coroutine - def refresh_unread_count(self): + async def refresh_unread_count(self): """Check the number of unread emails.""" if self._connection: - yield from self._connection.noop() - _, lines = yield from self._connection.search('UnSeen UnDeleted') + await self._connection.noop() + _, lines = await self._connection.search('UnSeen UnDeleted') self._unread_count = len(lines[0].split()) def disconnected(self): @@ -166,12 +162,11 @@ class ImapSensor(Entity): _LOGGER.warning("Lost %s (will attempt to reconnect)", self._server) self._connection = None - @asyncio.coroutine - def shutdown(self): + async def shutdown(self): """Close resources.""" if self._connection: if self._connection.has_pending_idle(): self._connection.idle_done() - yield from self._connection.logout() + await self._connection.logout() if self._idle_loop_task: self._idle_loop_task.cancel() diff --git a/homeassistant/components/sensor/insteon_plm.py b/homeassistant/components/sensor/insteon_plm.py new file mode 100644 index 00000000000..a72b8efbc05 --- /dev/null +++ b/homeassistant/components/sensor/insteon_plm.py @@ -0,0 +1,36 @@ +""" +Support for INSTEON dimmers via PowerLinc Modem. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.insteon_plm/ +""" +import asyncio +import logging + +from homeassistant.components.insteon_plm import InsteonPLMEntity +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ['insteon_plm'] + +_LOGGER = logging.getLogger(__name__) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Set up the INSTEON PLM device class for the hass platform.""" + plm = hass.data['insteon_plm'] + + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] + + _LOGGER.debug('Adding device %s entity %s to Sensor platform', + device.address.hex, device.states[state_key].name) + + new_entity = InsteonPLMSensorDevice(device, state_key) + + async_add_devices([new_entity]) + + +class InsteonPLMSensorDevice(InsteonPLMEntity, Entity): + """A Class for an Insteon device.""" diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index bdceb729e89..8eeb75fb0f1 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.knx/ """ -import asyncio import voluptuous as vol @@ -28,8 +27,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up sensor(s) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -72,11 +71,10 @@ class KNXSensor(Entity): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 5be24b1532c..8bf95d4ef6e 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyloopenergy==0.0.17'] +REQUIREMENTS = ['pyloopenergy==0.0.18'] CONF_ELEC = 'electricity' CONF_GAS = 'gas' diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index ac977e52fce..72ee8a7ce93 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS) + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_NAME, CONF_SHOW_ON_MAP, TEMP_CELSIUS) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -54,6 +55,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, }) @@ -63,6 +65,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): from luftdaten import Luftdaten name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) sensor_id = config.get(CONF_SENSORID) session = async_get_clientsession(hass) @@ -79,7 +82,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if luftdaten.data.values[variable] is None: _LOGGER.warning("It might be that sensor %s is not providing " "measurements for %s", sensor_id, variable) - devices.append(LuftdatenSensor(luftdaten, name, variable, sensor_id)) + devices.append( + LuftdatenSensor(luftdaten, name, variable, sensor_id, show_on_map)) async_add_devices(devices) @@ -87,7 +91,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class LuftdatenSensor(Entity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, name, sensor_type, sensor_id): + def __init__(self, luftdaten, name, sensor_type, sensor_id, show): """Initialize the Luftdaten sensor.""" self.luftdaten = luftdaten self._name = name @@ -95,6 +99,7 @@ class LuftdatenSensor(Entity): self._sensor_id = sensor_id self.sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._show_on_map = show @property def name(self): @@ -114,12 +119,15 @@ class LuftdatenSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" + onmap = ATTR_LATITUDE, ATTR_LONGITUDE + nomap = 'lat', 'long' + lat_format, lon_format = onmap if self._show_on_map else nomap try: attr = { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_SENSOR_ID: self._sensor_id, - 'lat': self.luftdaten.data.meta['latitude'], - 'long': self.luftdaten.data.meta['longitude'], + lat_format: self.luftdaten.data.meta['latitude'], + lon_format: self.luftdaten.data.meta['longitude'], } return attr except KeyError: diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 8ec6de60fb9..4dddaf45aa4 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -18,8 +18,6 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_MODULE = 'modules' - CONF_MODULES = 'modules' CONF_STATION = 'station' @@ -54,7 +52,7 @@ SENSOR_TYPES = { } MODULE_SCHEMA = vol.Schema({ - vol.Required(cv.string, default=[]): + vol.Required(cv.string): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index b0c40e8f007..87af51d2bbd 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['plexapi==3.0.5'] +REQUIREMENTS = ['plexapi==3.0.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 98252eb6f06..640e13e437a 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -105,9 +105,8 @@ RATING_MAPPING = [{ 'maximum': 12 }] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ZIP_CODE): cv.string, + vol.Required(CONF_ZIP_CODE): str, vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), }) @@ -211,21 +210,23 @@ class AllergyAverageSensor(BaseSensor): try: data_attr = getattr(self.data, self._data_params['data_attr']) - indices = [ - p['Index'] - for p in data_attr['Location']['periods'] - ] + indices = [p['Index'] for p in data_attr['Location']['periods']] + self._attrs[ATTR_TREND] = calculate_trend(indices) except KeyError: _LOGGER.error("Pollen.com API didn't return any data") return + try: + self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() + self._attrs[ATTR_STATE] = data_attr['Location']['State'] + self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] + except KeyError: + _LOGGER.debug('Location data not included in API response') + self._attrs[ATTR_CITY] = None + self._attrs[ATTR_STATE] = None + self._attrs[ATTR_ZIP_CODE] = None + average = round(mean(indices), 1) - - self._attrs[ATTR_TREND] = calculate_trend(indices) - self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() - self._attrs[ATTR_STATE] = data_attr['Location']['State'] - self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] - [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= average <= i['maximum'] @@ -245,31 +246,51 @@ class AllergyIndexSensor(BaseSensor): try: location_data = self.data.current_data['Location'] + [period] = [ + p for p in location_data['periods'] + if p['Type'] == self._data_params['key'] + ] + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= period['Index'] <= i['maximum'] + ] + self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] + self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] + self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0][ + 'PlantType'] + self._attrs[ATTR_RATING] = rating + except KeyError: _LOGGER.error("Pollen.com API didn't return any data") return - [period] = [ - p for p in location_data['periods'] - if p['Type'] == self._data_params['key'] - ] + try: + self._attrs[ATTR_CITY] = location_data['City'].title() + self._attrs[ATTR_STATE] = location_data['State'] + self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] + except KeyError: + _LOGGER.debug('Location data not included in API response') + self._attrs[ATTR_CITY] = None + self._attrs[ATTR_STATE] = None + self._attrs[ATTR_ZIP_CODE] = None - self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] - self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] - self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0]['PlantType'] - self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] - self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] - self._attrs[ATTR_TREND] = self.data.outlook_data[ - 'Trend'].title() - self._attrs[ATTR_CITY] = location_data['City'].title() - self._attrs[ATTR_STATE] = location_data['State'] - self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] + try: + self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] + except KeyError: + _LOGGER.debug('Outlook data not included in API response') + self._attrs[ATTR_OUTLOOK] = None - [rating] = [ - i['label'] for i in RATING_MAPPING - if i['minimum'] <= period['Index'] <= i['maximum'] - ] - self._attrs[ATTR_RATING] = rating + try: + self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] + except KeyError: + _LOGGER.debug('Season data not included in API response') + self._attrs[ATTR_SEASON] = None + + try: + self._attrs[ATTR_TREND] = self.data.outlook_data['Trend'].title() + except KeyError: + _LOGGER.debug('Trend data not included in API response') + self._attrs[ATTR_TREND] = None self._state = period['Index'] self._unit = 'index' @@ -289,8 +310,7 @@ class DataBase(object): data = {} try: data = getattr(getattr(self._client, module), operation)() - _LOGGER.debug('Received "%s_%s" data: %s', module, - operation, data) + _LOGGER.debug('Received "%s_%s" data: %s', module, operation, data) except HTTPError as exc: _LOGGER.error('An error occurred while retrieving data') _LOGGER.debug(exc) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 19f5a1c271e..74bfaa38f02 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -35,7 +35,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, vol.Optional(CONF_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -130,18 +130,20 @@ class RestSensor(Entity): if self._json_attrs: self._attributes = {} - try: - json_dict = json.loads(value) - if isinstance(json_dict, dict): - attrs = {k: json_dict[k] for k in self._json_attrs - if k in json_dict} - self._attributes = attrs - else: - _LOGGER.warning("JSON result was not a dictionary") - except ValueError: - _LOGGER.warning("REST result could not be parsed as JSON") - _LOGGER.debug("Erroneous JSON: %s", value) - + if value: + try: + json_dict = json.loads(value) + if isinstance(json_dict, dict): + attrs = {k: json_dict[k] for k in self._json_attrs + if k in json_dict} + self._attributes = attrs + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning("REST result could not be parsed as JSON") + _LOGGER.debug("Erroneous JSON: %s", value) + else: + _LOGGER.warning("Empty reply found when expecting JSON data") if value is None: value = STATE_UNKNOWN elif self._value_template is not None: diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py index e02f3cac2b0..b04b7727e40 100644 --- a/homeassistant/components/sensor/season.py +++ b/homeassistant/components/sensor/season.py @@ -21,10 +21,10 @@ _LOGGER = logging.getLogger(__name__) NORTHERN = 'northern' SOUTHERN = 'southern' EQUATOR = 'equator' -STATE_SPRING = 'Spring' -STATE_SUMMER = 'Summer' -STATE_AUTUMN = 'Autumn' -STATE_WINTER = 'Winter' +STATE_SPRING = 'spring' +STATE_SUMMER = 'summer' +STATE_AUTUMN = 'autumn' +STATE_WINTER = 'winter' TYPE_ASTRONOMICAL = 'astronomical' TYPE_METEOROLOGICAL = 'meteorological' VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py new file mode 100644 index 00000000000..5eee9053db5 --- /dev/null +++ b/homeassistant/components/sensor/sense.py @@ -0,0 +1,152 @@ +""" +Support for monitoring a Sense energy sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sense/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_EMAIL, CONF_PASSWORD, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['sense_energy==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +ACTIVE_NAME = "Energy" +PRODUCTION_NAME = "Production" +CONSUMPTION_NAME = "Usage" + +ACTIVE_TYPE = 'active' + + +class SensorConfig(object): + """Data structure holding sensor config.""" + + def __init__(self, name, sensor_type): + """Sensor name and type to pass to API.""" + self.name = name + self.sensor_type = sensor_type + + +# Sensor types/ranges +SENSOR_TYPES = {'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), + 'daily': SensorConfig('Daily', 'DAY'), + 'weekly': SensorConfig('Weekly', 'WEEK'), + 'monthly': SensorConfig('Monthly', 'MONTH'), + 'yearly': SensorConfig('Yearly', 'YEAR')} + +# Production/consumption variants +SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] + +# Valid sensors for configuration +VALID_SENSORS = ['%s_%s' % (typ, var) + for typ in SENSOR_TYPES + for var in SENSOR_VARIANTS] + +ICON = 'mdi:flash' + +MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) +MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=60) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(VALID_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Sense sensor.""" + from sense_energy import Senseable + + username = config.get(CONF_EMAIL) + password = config.get(CONF_PASSWORD) + + data = Senseable(username, password) + + @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) + def update_trends(): + """Update the daily power usage.""" + data.update_trend_data() + + @Throttle(MIN_TIME_BETWEEN_ACTIVE_UPDATES) + def update_active(): + """Update the active power usage.""" + data.get_realtime() + + devices = [] + for sensor in config.get(CONF_MONITORED_CONDITIONS): + config_name, prod = sensor.rsplit('_', 1) + name = SENSOR_TYPES[config_name].name + sensor_type = SENSOR_TYPES[config_name].sensor_type + is_production = prod == PRODUCTION_NAME.lower() + if sensor_type == ACTIVE_TYPE: + update_call = update_active + else: + update_call = update_trends + devices.append(Sense(data, name, sensor_type, + is_production, update_call)) + + add_devices(devices) + + +class Sense(Entity): + """Implementation of a Sense energy sensor.""" + + def __init__(self, data, name, sensor_type, is_production, update_call): + """Initialize the sensor.""" + name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME + self._name = "%s %s" % (name, name_type) + self._data = data + self._sensor_type = sensor_type + self.update_sensor = update_call + self._is_production = is_production + self._state = None + + if sensor_type == ACTIVE_TYPE: + self._unit_of_measurement = 'W' + else: + self._unit_of_measurement = 'kWh' + + @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 of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the latest data, update state.""" + self.update_sensor() + + if self._sensor_type == ACTIVE_TYPE: + if self._is_production: + self._state = round(self._data.active_solar_power) + else: + self._state = round(self._data.active_power) + else: + state = self._data.get_trend(self._sensor_type, + self._is_production) + self._state = round(state, 1) diff --git a/homeassistant/components/sensor/serial_pm.py b/homeassistant/components/sensor/serial_pm.py index a031f9cbd56..d2157066625 100644 --- a/homeassistant/components/sensor/serial_pm.py +++ b/homeassistant/components/sensor/serial_pm.py @@ -48,7 +48,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] for pmname in coll.supported_values(): - if config.get(CONF_NAME) is None: + if config.get(CONF_NAME) is not None: name = '{} PM{}'.format(config.get(CONF_NAME), pmname) else: name = 'PM{}'.format(pmname) diff --git a/homeassistant/components/sensor/simulated.py b/homeassistant/components/sensor/simulated.py new file mode 100644 index 00000000000..7091146e3ac --- /dev/null +++ b/homeassistant/components/sensor/simulated.py @@ -0,0 +1,146 @@ +""" +Adds a simulated sensor. + +For more details about this platform, refer to the documentation at +https://home-assistant.io/components/sensor.simulated/ +""" +import asyncio +import datetime as datetime +import math +from random import Random +import logging + +import voluptuous as vol + +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_NAME +from homeassistant.components.sensor import PLATFORM_SCHEMA + +_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = datetime.timedelta(seconds=30) +ICON = 'mdi:chart-line' + +CONF_UNIT = 'unit' +CONF_AMP = 'amplitude' +CONF_MEAN = 'mean' +CONF_PERIOD = 'period' +CONF_PHASE = 'phase' +CONF_FWHM = 'spread' +CONF_SEED = 'seed' + +DEFAULT_NAME = 'simulated' +DEFAULT_UNIT = 'value' +DEFAULT_AMP = 1 +DEFAULT_MEAN = 0 +DEFAULT_PERIOD = 60 +DEFAULT_PHASE = 0 +DEFAULT_FWHM = 0 +DEFAULT_SEED = 999 + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, + vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), + vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), + vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, + vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), + vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), + vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the simulated sensor.""" + name = config.get(CONF_NAME) + unit = config.get(CONF_UNIT) + amp = config.get(CONF_AMP) + mean = config.get(CONF_MEAN) + period = config.get(CONF_PERIOD) + phase = config.get(CONF_PHASE) + fwhm = config.get(CONF_FWHM) + seed = config.get(CONF_SEED) + + sensor = SimulatedSensor( + name, unit, amp, mean, period, phase, fwhm, seed + ) + add_devices([sensor], True) + + +class SimulatedSensor(Entity): + """Class for simulated sensor.""" + + def __init__(self, name, unit, amp, mean, period, phase, fwhm, seed): + """Init the class.""" + self._name = name + self._unit = unit + self._amp = amp + self._mean = mean + self._period = period + self._phase = phase # phase in degrees + self._fwhm = fwhm + self._seed = seed + self._random = Random(seed) # A local seeded Random + self._start_time = dt_util.utcnow() + self._state = None + + def time_delta(self): + """"Return the time delta.""" + dt0 = self._start_time + dt1 = dt_util.utcnow() + return dt1 - dt0 + + def signal_calc(self): + """Calculate the signal.""" + mean = self._mean + amp = self._amp + time_delta = self.time_delta().total_seconds()*1e6 # to milliseconds + period = self._period*1e6 # to milliseconds + fwhm = self._fwhm/2 + phase = math.radians(self._phase) + if period == 0: + periodic = 0 + else: + periodic = amp * (math.sin((2*math.pi*time_delta/period) + phase)) + noise = self._random.gauss(mu=0, sigma=fwhm) + return mean + periodic + noise + + @asyncio.coroutine + def async_update(self): + """Update the sensor.""" + self._state = self.signal_calc() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._unit + + @property + def device_state_attributes(self): + """Return other details about the sensor state.""" + attr = { + 'amplitude': self._amp, + 'mean': self._mean, + 'period': self._period, + 'phase': self._phase, + 'spread': self._fwhm, + 'seed': self._seed, + } + return attr diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index 395c082f9d2..50d60bfc426 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -24,9 +24,17 @@ CONF_QUERIES = 'queries' CONF_QUERY = 'query' CONF_COLUMN_NAME = 'column' + +def validate_sql_select(value): + """Validate that value is a SQL SELECT query.""" + if not value.lstrip().lower().startswith('select'): + raise vol.Invalid('Only SELECT queries allowed') + return value + + _QUERY_SCHEME = vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_QUERY): cv.string, + vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select), vol.Required(CONF_COLUMN_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, @@ -123,21 +131,23 @@ class SQLSensor(Entity): try: sess = self.sessionmaker() result = sess.execute(self._query) + + if not result.returns_rows or result.rowcount == 0: + _LOGGER.warning("%s returned no results", self._query) + self._state = None + self._attributes = {} + return + + for res in result: + _LOGGER.debug("result = %s", res.items()) + data = res[self._column_name] + self._attributes = {k: v for k, v in res.items()} except sqlalchemy.exc.SQLAlchemyError as err: _LOGGER.error("Error executing query %s: %s", self._query, err) return finally: sess.close() - for res in result: - _LOGGER.debug(res.items()) - data = res[self._column_name] - self._attributes = {k: str(v) for k, v in res.items()} - - if data is None: - _LOGGER.error("%s returned no results", self._query) - return - if self._template is not None: self._state = self._template.async_render_with_possible_json_value( data, None) diff --git a/homeassistant/components/sensor/strings.season.json b/homeassistant/components/sensor/strings.season.json new file mode 100644 index 00000000000..63136320d74 --- /dev/null +++ b/homeassistant/components/sensor/strings.season.json @@ -0,0 +1,8 @@ +{ + "state": { + "spring": "Spring", + "summer": "Summer", + "autumn": "Autumn", + "winter": "Winter" + } +} diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 3aed9d5a21b..79d5c261b88 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -29,9 +29,9 @@ SENSOR_TYPES = { 'ipv4_address': ['IPv4 address', '', 'mdi:server-network'], 'ipv6_address': ['IPv6 address', '', 'mdi:server-network'], 'last_boot': ['Last boot', '', 'mdi:clock'], - 'load_15m': ['Load (15m)', '', 'mdi:memory'], - 'load_1m': ['Load (1m)', '', 'mdi:memory'], - 'load_5m': ['Load (5m)', '', 'mdi:memory'], + 'load_15m': ['Load (15m)', ' ', 'mdi:memory'], + 'load_1m': ['Load (1m)', ' ', 'mdi:memory'], + 'load_5m': ['Load (5m)', ' ', 'mdi:memory'], 'memory_free': ['Memory free', 'MiB', 'mdi:memory'], 'memory_use': ['Memory use', 'MiB', 'mdi:memory'], 'memory_use_percent': ['Memory use (percent)', '%', 'mdi:memory'], diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index 33e5c0cf4ce..9c4263422ff 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -31,11 +31,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds SENSOR_TYPES = { - 'usage': ['Usage', PERCENT, 'mdi:percent'], + 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'], 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'], 'limit': ['Data limit', GIGABYTES, 'mdi:download'], 'onpeak_download': ['On Peak Download', GIGABYTES, 'mdi:download'], - 'onpeak_upload': ['On Peak Upload ', GIGABYTES, 'mdi:upload'], + 'onpeak_upload': ['On Peak Upload', GIGABYTES, 'mdi:upload'], 'onpeak_total': ['On Peak Total', GIGABYTES, 'mdi:download'], 'offpeak_download': ['Off Peak download', GIGABYTES, 'mdi:download'], 'offpeak_upload': ['Off Peak Upload', GIGABYTES, 'mdi:upload'], @@ -128,7 +128,9 @@ class TekSavvyData(object): self.websession = websession self.api_key = api_key self.bandwidth_cap = bandwidth_cap - self.data = {"limit": self.bandwidth_cap} + # Set unlimited users to infinite, otherwise the cap. + self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \ + else {"limit": float('inf')} @asyncio.coroutine @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -143,17 +145,27 @@ class TekSavvyData(object): if req.status != 200: _LOGGER.error("Request failed with status: %u", req.status) return False - data = yield from req.json() - for (api, ha_name) in API_HA_MAP: - self.data[ha_name] = float(data["value"][0][api]) - on_peak_download = self.data["onpeak_download"] - on_peak_upload = self.data["onpeak_upload"] - off_peak_download = self.data["offpeak_download"] - off_peak_upload = self.data["offpeak_upload"] - limit = self.data["limit"] - self.data["usage"] = 100*on_peak_download/self.bandwidth_cap - self.data["usage_gb"] = on_peak_download - self.data["onpeak_total"] = on_peak_download + on_peak_upload - self.data["offpeak_total"] = off_peak_download + off_peak_upload - self.data["onpeak_remaining"] = limit - on_peak_download - return True + + try: + data = yield from req.json() + for (api, ha_name) in API_HA_MAP: + self.data[ha_name] = float(data["value"][0][api]) + on_peak_download = self.data["onpeak_download"] + on_peak_upload = self.data["onpeak_upload"] + off_peak_download = self.data["offpeak_download"] + off_peak_upload = self.data["offpeak_upload"] + limit = self.data["limit"] + # Support "unlimited" users + if self.bandwidth_cap > 0: + self.data["usage"] = 100*on_peak_download/self.bandwidth_cap + else: + self.data["usage"] = 0 + self.data["usage_gb"] = on_peak_download + self.data["onpeak_total"] = on_peak_download + on_peak_upload + self.data["offpeak_total"] =\ + off_peak_download + off_peak_upload + self.data["onpeak_remaining"] = limit - on_peak_download + return True + except ValueError: + _LOGGER.error("JSON Decode Failed") + return False diff --git a/homeassistant/components/sensor/tesla.py b/homeassistant/components/sensor/tesla.py index 74e74262710..1ffc97bb137 100644 --- a/homeassistant/components/sensor/tesla.py +++ b/homeassistant/components/sensor/tesla.py @@ -78,6 +78,14 @@ class TeslaSensor(TeslaDevice, Entity): self._unit = TEMP_FAHRENHEIT else: self._unit = TEMP_CELSIUS + elif (self.tesla_device.bin_type == 0xA or + self.tesla_device.bin_type == 0xB): + self.current_value = self.tesla_device.get_value() + tesla_dist_unit = self.tesla_device.measurement + if tesla_dist_unit == 'LENGTH_MILES': + self._unit = LENGTH_MILES + else: + self._unit = LENGTH_KILOMETERS else: self.current_value = self.tesla_device.get_value() if self.tesla_device.bin_type == 0x5: diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 519ff05cbd8..a5f490c8d51 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -9,16 +9,18 @@ import asyncio import logging from datetime import timedelta +import aiohttp import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util -REQUIREMENTS = ['pyTibber==0.2.1'] +REQUIREMENTS = ['pyTibber==0.3.2'] _LOGGER = logging.getLogger(__name__) @@ -30,17 +32,21 @@ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=1) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Tibber sensor.""" - import Tibber - tibber = Tibber.Tibber(config[CONF_ACCESS_TOKEN], - websession=async_get_clientsession(hass)) - yield from tibber.update_info() - dev = [] - for home in tibber.get_homes(): - yield from home.update_info() - dev.append(TibberSensor(home)) + import tibber + tibber_connection = tibber.Tibber(config[CONF_ACCESS_TOKEN], + websession=async_get_clientsession(hass)) + + try: + await tibber_connection.update_info() + dev = [] + for home in tibber_connection.get_homes(): + await home.update_info() + dev.append(TibberSensor(home)) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() async_add_devices(dev, True) @@ -53,25 +59,41 @@ class TibberSensor(Entity): self._tibber_home = tibber_home self._last_updated = None self._state = None - self._device_state_attributes = None - self._unit_of_measurement = None - self._name = 'Electricity price {}'.format(self._tibber_home.address1) + self._device_state_attributes = {} + self._unit_of_measurement = self._tibber_home.price_unit + self._name = 'Electricity price {}'.format(tibber_home.address1) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data and updates the states.""" + now = dt_util.utcnow() if self._tibber_home.current_price_total and self._last_updated and \ dt_util.as_utc(dt_util.parse_datetime(self._last_updated)).hour\ - == dt_util.utcnow().hour: + == now.hour: return - yield from self._tibber_home.update_current_price_info() + def _find_current_price(): + for key, price_total in self._tibber_home.price_total.items(): + price_time = dt_util.as_utc(dt_util.parse_datetime(key)) + time_diff = (now - price_time).total_seconds()/60 + if time_diff >= 0 and time_diff < 60: + self._state = round(price_total, 2) + self._last_updated = key + return True + return False - self._state = self._tibber_home.current_price_total - self._last_updated = self._tibber_home.current_price_info.\ - get('startsAt') - self._device_state_attributes = self._tibber_home.current_price_info - self._unit_of_measurement = self._tibber_home.price_unit + if _find_current_price(): + return + + _LOGGER.debug("No cached data found, so asking for new data") + await self._tibber_home.update_info() + await self._tibber_home.update_price_info() + data = self._tibber_home.info['viewer']['home'] + self._device_state_attributes['app_nickname'] = data['appNickname'] + self._device_state_attributes['grid_company'] =\ + data['meteringPointData']['gridCompany'] + self._device_state_attributes['estimated_annual_consumption'] =\ + data['meteringPointData']['estimatedAnnualConsumption'] + _find_current_price() @property def device_state_attributes(self): @@ -97,3 +119,9 @@ class TibberSensor(Entity): def unit_of_measurement(self): """Return the unit of measurement of this entity.""" return self._unit_of_measurement + + @property + def unique_id(self): + """Return a unique ID.""" + home = self._tibber_home.info['viewer']['home'] + return home['meteringPointData']['consumptionEan'] diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py index 2fd1a66e790..0c7b8b48f62 100644 --- a/homeassistant/components/sensor/xbox_live.py +++ b/homeassistant/components/sensor/xbox_live.py @@ -69,7 +69,9 @@ class XboxSensor(Entity): if profile.get('success', True) and profile.get('code') is None: self.success_init = True self._gamertag = profile.get('gamertag') + self._gamerscore = profile.get('gamerscore') self._picture = profile.get('gamerpicSmallSslImagePath') + self._tier = profile.get('tier') else: _LOGGER.error("Can't get user profile %s. " "Error Code: %s Description: %s", @@ -92,6 +94,9 @@ class XboxSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" attributes = {} + attributes['gamerscore'] = self._gamerscore + attributes['tier'] = self._tier + for device in self._presence: for title in device.get('titles'): attributes[ diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index c2498d88822..33bbdc32308 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -7,6 +7,14 @@ from homeassistant.const import TEMP_CELSIUS _LOGGER = logging.getLogger(__name__) +SENSOR_TYPES = { + 'temperature': [TEMP_CELSIUS, 'mdi:thermometer'], + 'humidity': ['%', 'mdi:water-percent'], + 'illumination': ['lm', 'mdi:weather-sunset'], + 'lux': ['lx', 'mdi:weather-sunset'], + 'pressure': ['hPa', 'mdi:gauge'] +} + def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Xiaomi devices.""" @@ -42,19 +50,21 @@ class XiaomiSensor(XiaomiDevice): self._data_key = data_key XiaomiDevice.__init__(self, device, name, xiaomi_hub) + @property + def icon(self): + """Return the icon to use in the frontend.""" + try: + return SENSOR_TYPES.get(self._data_key)[1] + except TypeError: + return None + @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' + try: + return SENSOR_TYPES.get(self._data_key)[0] + except TypeError: + return None @property def state(self): diff --git a/homeassistant/components/sensor/zestimate.py b/homeassistant/components/sensor/zestimate.py new file mode 100644 index 00000000000..d8c759f1727 --- /dev/null +++ b/homeassistant/components/sensor/zestimate.py @@ -0,0 +1,134 @@ +""" +Support for zestimate data from zillow.com. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.zestimate/ +""" +from datetime import timedelta +import logging + +import requests +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_API_KEY, + CONF_NAME, ATTR_ATTRIBUTION) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['xmltodict==0.11.0'] + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'http://www.zillow.com/webservice/GetZestimate.htm' + +CONF_ZPID = 'zpid' +CONF_ATTRIBUTION = "Data provided by Zillow.com" + +DEFAULT_NAME = 'Zestimate' +NAME = 'zestimate' +ZESTIMATE = '{}:{}'.format(DEFAULT_NAME, NAME) + +ICON = 'mdi:home-variant' + +ATTR_AMOUNT = 'amount' +ATTR_CHANGE = 'amount_change_30_days' +ATTR_CURRENCY = 'amount_currency' +ATTR_LAST_UPDATED = 'amount_last_updated' +ATTR_VAL_HI = 'valuation_range_high' +ATTR_VAL_LOW = 'valuation_range_low' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZPID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Zestimate sensor.""" + name = config.get(CONF_NAME) + properties = config[CONF_ZPID] + params = {'zws-id': config[CONF_API_KEY]} + + sensors = [] + for zpid in properties: + params['zpid'] = zpid + sensors.append(ZestimateDataSensor(name, params)) + add_devices(sensors, True) + + +class ZestimateDataSensor(Entity): + """Implementation of a Zestimate sensor.""" + + def __init__(self, name, params): + """Initialize the sensor.""" + self._name = name + self.params = params + self.data = None + self.address = None + 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.""" + try: + return round(float(self._state), 1) + except ValueError: + return None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = {} + if self.data is not None: + attributes = self.data + attributes['address'] = self.address + attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + return attributes + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data and update the states.""" + import xmltodict + try: + response = requests.get(_RESOURCE, params=self.params, timeout=5) + data = response.content.decode('utf-8') + data_dict = xmltodict.parse(data).get(ZESTIMATE) + error_code = int(data_dict['message']['code']) + if error_code != 0: + _LOGGER.error('The API returned: %s', + data_dict['message']['text']) + return + except requests.exceptions.ConnectionError: + _LOGGER.error('Unable to retrieve data from %s', _RESOURCE) + return + data = data_dict['response'][NAME] + details = {} + details[ATTR_AMOUNT] = data['amount']['#text'] + details[ATTR_CURRENCY] = data['amount']['@currency'] + details[ATTR_LAST_UPDATED] = data['last-updated'] + details[ATTR_CHANGE] = int(data['valueChange']['#text']) + details[ATTR_VAL_HI] = int(data['valuationRange']['high']['#text']) + details[ATTR_VAL_LOW] = int(data['valuationRange']['low']['#text']) + self.address = data_dict['response']['address']['street'] + self.data = details + if self.data is not None: + self._state = self.data[ATTR_AMOUNT] + else: + self._state = None + _LOGGER.error('Unable to parase Zestimate data from response') diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 522939a213a..519d3b98704 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -509,3 +509,38 @@ homeassistant: entity_id: description: The entity_id of the device to turn off. example: light.living_room + +xiaomi_aqara: + play_ringtone: + description: Play a specific ringtone. The version of the gateway firmware must be 1.4.1_145 at least. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + ringtone_id: + description: One of the allowed ringtone ids. + example: 8 + ringtone_vol: + description: The volume in percent. + example: 30 + stop_ringtone: + description: Stops a playing ringtone immediately. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + add_device: + description: Enables the join permission of the Xiaomi Aqara Gateway for 30 seconds. A new device can be added afterwards by pressing the pairing button once. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + remove_device: + description: Removes a specific device. The removal is required if a device shall be paired with another gateway. + fields: + gw_mac: + description: MAC address of the Xiaomi Aqara Gateway. + example: 34ce00880088 + device_id: + description: Hardware address of the device to remove. + example: 158d0000000000 diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 2452188a889..0ca0fef6e06 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -43,7 +43,7 @@ def async_setup(hass, config): hass.http.register_view(ClearCompletedItemsView) hass.components.conversation.async_register(INTENT_ADD_ITEM, [ - 'Add {item} to my shopping list', + 'Add [the] [a] [an] {item} to my shopping list', ]) hass.components.conversation.async_register(INTENT_LAST_ITEMS, [ 'What is on my shopping list' @@ -173,10 +173,9 @@ class UpdateShoppingListItemView(http.HomeAssistantView): url = '/api/shopping_list/item/{item_id}' name = "api:shopping_list:item:id" - @callback - def post(self, request, item_id): + async def post(self, request, item_id): """Update a shopping list item.""" - data = yield from request.json() + data = await request.json() try: item = request.app['hass'].data[DOMAIN].async_update(item_id, data) diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index 72477a5a65f..10544b3ef53 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -219,7 +219,8 @@ class SpcWebGateway: url = self._build_url(resource) try: _LOGGER.debug("Attempting to retrieve SPC data from %s", url) - session = aiohttp.ClientSession() + session = \ + self._hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(10, loop=self._hass.loop): action = session.get if use_get else session.put response = yield from action(url) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 66a416c5bea..9a35198628a 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -93,33 +93,31 @@ def toggle(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_TOGGLE, data) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for switches.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_handle_switch_service(service): + async def async_handle_switch_service(service): """Handle calls to the switch services.""" target_switches = component.async_extract_from_service(service) update_tasks = [] for switch in target_switches: if service.service == SERVICE_TURN_ON: - yield from switch.async_turn_on() + await switch.async_turn_on() elif service.service == SERVICE_TOGGLE: - yield from switch.async_toggle() + await switch.async_toggle() else: - yield from switch.async_turn_off() + await switch.async_turn_off() if not switch.should_poll: continue update_tasks.append(switch.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_TURN_OFF, async_handle_switch_service, diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index d4b02749c1b..50b5ba93b85 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -13,8 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['https://github.com/rkabadi/pyedimax/archive/' - '365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1'] +REQUIREMENTS = ['pyedimax==0.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index 0b584e14b8d..5f9482ce955 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -2,14 +2,13 @@ Support for INSTEON dimmers via PowerLinc Modem. For more details about this component, please refer to the documentation at -https://home-assistant.io/components/insteon_plm/ +https://home-assistant.io/components/switch.insteon_plm/ """ -import logging import asyncio +import logging -from homeassistant.core import callback -from homeassistant.components.switch import (SwitchDevice) -from homeassistant.loader import get_component +from homeassistant.components.insteon_plm import InsteonPLMEntity +from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['insteon_plm'] @@ -21,77 +20,54 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the INSTEON PLM device class for the hass platform.""" plm = hass.data['insteon_plm'] - device_list = [] - for device in discovery_info: - name = device.get('address') - address = device.get('address_hex') + address = discovery_info['address'] + device = plm.devices[address] + state_key = discovery_info['state_key'] - _LOGGER.info('Registered %s with switch platform.', name) + state_name = device.states[state_key].name - device_list.append( - InsteonPLMSwitchDevice(hass, plm, address, name) - ) + _LOGGER.debug('Adding device %s entity %s to Switch platform', + device.address.hex, device.states[state_key].name) - async_add_devices(device_list) + new_entity = None + if state_name in ['lightOnOff', 'outletTopOnOff', 'outletBottomOnOff']: + new_entity = InsteonPLMSwitchDevice(device, state_key) + elif state_name == 'openClosedRelay': + new_entity = InsteonPLMOpenClosedDevice(device, state_key) + + if new_entity is not None: + async_add_devices([new_entity]) -class InsteonPLMSwitchDevice(SwitchDevice): +class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): """A Class for an Insteon device.""" - def __init__(self, hass, plm, address, name): - """Initialize the switch.""" - self._hass = hass - self._plm = plm.protocol - self._address = address - self._name = name - - self._plm.add_update_callback( - self.async_switch_update, {'address': self._address}) - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def address(self): - """Return the address of the node.""" - return self._address - - @property - def name(self): - """Return the name of the node.""" - return self._name - @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._plm.get_device_attr(self._address, 'onlevel') - _LOGGER.debug('on level for %s is %s', self._address, onlevel) + onlevel = self._insteon_device_state.value return bool(onlevel) - @property - def device_state_attributes(self): - """Provide attributes for display on device card.""" - insteon_plm = get_component('insteon_plm') - return insteon_plm.common_attributes(self) - - def get_attr(self, key): - """Return specified attribute for this device.""" - return self._plm.get_device_attr(self.address, key) - - @callback - def async_switch_update(self, message): - """Receive notification from transport that new data exists.""" - _LOGGER.info('Received update callback from PLM for %s', self._address) - self._hass.async_add_job(self.async_update_ha_state()) - @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" - self._plm.turn_on(self._address) + self._insteon_device_state.on() @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn device off.""" - self._plm.turn_off(self._address) + self._insteon_device_state.off() + + +class InsteonPLMOpenClosedDevice(InsteonPLMEntity, SwitchDevice): + """A Class for an Insteon device.""" + + @asyncio.coroutine + def async_turn_on(self, **kwargs): + """Turn device on.""" + self._insteon_device_state.open() + + @asyncio.coroutine + def async_turn_off(self, **kwargs): + """Turn device off.""" + self._insteon_device_state.close() diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index 86a9adf0495..a96f96a9c5c 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -4,7 +4,6 @@ Support for KNX/IP switches. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.knx/ """ -import asyncio import voluptuous as vol @@ -27,8 +26,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up switch(es) for KNX platform.""" if discovery_info is not None: async_add_devices_discovery(hass, discovery_info, async_add_devices) @@ -71,11 +70,10 @@ class KNXSwitch(SwitchDevice): @callback def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" - @asyncio.coroutine - def after_update_callback(device): + async def after_update_callback(device): """Call after device was updated.""" # pylint: disable=unused-argument - yield from self.async_update_ha_state() + await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @property @@ -98,12 +96,10 @@ class KNXSwitch(SwitchDevice): """Return true if device is on.""" return self.device.state - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - yield from self.device.set_on() + await self.device.set_on() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - yield from self.device.set_off() + await self.device.set_off() diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index b68cc038e89..9c589d1d95b 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -13,8 +13,8 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, CONF_USERNAME, - CONF_PASSWORD) + CONF_HEADERS, CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD, + CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -34,6 +34,7 @@ SUPPORT_REST_METHODS = ['post', 'put'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_RESOURCE): cv.url, + vol.Optional(CONF_HEADERS): {cv.string: cv.string}, vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template, vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template, vol.Optional(CONF_IS_ON_TEMPLATE): cv.template, @@ -54,6 +55,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): body_on = config.get(CONF_BODY_ON) is_on_template = config.get(CONF_IS_ON_TEMPLATE) method = config.get(CONF_METHOD) + headers = config.get(CONF_HEADERS) name = config.get(CONF_NAME) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -72,8 +74,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): timeout = config.get(CONF_TIMEOUT) try: - switch = RestSwitch(name, resource, method, auth, body_on, body_off, - is_on_template, timeout) + switch = RestSwitch(name, resource, method, headers, auth, body_on, + body_off, is_on_template, timeout) req = yield from switch.get_device_state(hass) if req.status >= 400: @@ -90,13 +92,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class RestSwitch(SwitchDevice): """Representation of a switch that can be toggled using REST.""" - def __init__(self, name, resource, method, auth, body_on, body_off, - is_on_template, timeout): + def __init__(self, name, resource, method, headers, auth, body_on, + body_off, is_on_template, timeout): """Initialize the REST switch.""" self._state = None self._name = name self._resource = resource self._method = method + self._headers = headers self._auth = auth self._body_on = body_on self._body_off = body_off @@ -153,7 +156,8 @@ class RestSwitch(SwitchDevice): with async_timeout.timeout(self._timeout, loop=self.hass.loop): req = yield from getattr(websession, self._method)( - self._resource, auth=self._auth, data=bytes(body, 'utf-8')) + self._resource, auth=self._auth, data=bytes(body, 'utf-8'), + headers=self._headers) return req @asyncio.coroutine diff --git a/homeassistant/components/switch/tesla.py b/homeassistant/components/switch/tesla.py index 2f105a709ad..0e1b7e819f7 100644 --- a/homeassistant/components/switch/tesla.py +++ b/homeassistant/components/switch/tesla.py @@ -17,8 +17,13 @@ DEPENDENCIES = ['tesla'] def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tesla switch platform.""" - devices = [ChargerSwitch(device, hass.data[TESLA_DOMAIN]['controller']) - for device in hass.data[TESLA_DOMAIN]['devices']['switch']] + controller = hass.data[TESLA_DOMAIN]['devices']['controller'] + devices = [] + for device in hass.data[TESLA_DOMAIN]['devices']['switch']: + if device.bin_type == 0x8: + devices.append(ChargerSwitch(device, controller)) + elif device.bin_type == 0x9: + devices.append(RangeSwitch(device, controller)) add_devices(devices, True) @@ -38,7 +43,7 @@ class ChargerSwitch(TeslaDevice, SwitchDevice): def turn_off(self, **kwargs): """Send the off command.""" - _LOGGER.debug("Disable charging for: %s", self._name) + _LOGGER.debug("Disable charging for: %s", self._name) self.tesla_device.stop_charge() @property @@ -52,3 +57,35 @@ class ChargerSwitch(TeslaDevice, SwitchDevice): self.tesla_device.update() self._state = STATE_ON if self.tesla_device.is_charging() \ else STATE_OFF + + +class RangeSwitch(TeslaDevice, SwitchDevice): + """Representation of a Tesla max range charging switch.""" + + def __init__(self, tesla_device, controller): + """Initialise of the switch.""" + self._state = None + super().__init__(tesla_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id) + + def turn_on(self, **kwargs): + """Send the on command.""" + _LOGGER.debug("Enable max range charging: %s", self._name) + self.tesla_device.set_max() + + def turn_off(self, **kwargs): + """Send the off command.""" + _LOGGER.debug("Disable max range charging: %s", self._name) + self.tesla_device.set_standard() + + @property + def is_on(self): + """Get whether the switch is in on state.""" + return self._state == STATE_ON + + def update(self): + """Update the state of the switch.""" + _LOGGER.debug("Updating state for: %s", self._name) + self.tesla_device.update() + self._state = STATE_ON if self.tesla_device.is_maxrange() \ + else STATE_OFF diff --git a/homeassistant/components/switch/upcloud.py b/homeassistant/components/switch/upcloud.py new file mode 100644 index 00000000000..5c3af45bede --- /dev/null +++ b/homeassistant/components/switch/upcloud.py @@ -0,0 +1,48 @@ +""" +Support for interacting with UpCloud servers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.upcloud/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import STATE_OFF +import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) +from homeassistant.components.upcloud import ( + UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['upcloud'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the UpCloud server switch.""" + upcloud = hass.data[DATA_UPCLOUD] + + servers = config.get(CONF_SERVERS) + + devices = [UpCloudSwitch(upcloud, uuid) for uuid in servers] + + add_devices(devices, True) + + +class UpCloudSwitch(UpCloudServerEntity, SwitchDevice): + """Representation of an UpCloud server switch.""" + + def turn_on(self, **kwargs): + """Start the server.""" + if self.state == STATE_OFF: + self.data.start() + + def turn_off(self, **kwargs): + """Stop the server.""" + if self.is_on: + self.data.stop() diff --git a/homeassistant/components/switch/volvooncall.py b/homeassistant/components/switch/volvooncall.py index 9e20ddb5e7e..c1b18a11795 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -1,7 +1,7 @@ """ Support for Volvo heater. -This platform uses the Telldus Live online service. +This platform uses the Volvo online service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.volvooncall/ diff --git a/homeassistant/components/switch/xiaomi_aqara.py b/homeassistant/components/switch/xiaomi_aqara.py index 1688b6b89e1..939fc70660a 100644 --- a/homeassistant/components/switch/xiaomi_aqara.py +++ b/homeassistant/components/switch/xiaomi_aqara.py @@ -28,22 +28,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if model == 'plug': devices.append(XiaomiGenericSwitch(device, "Plug", 'status', True, gateway)) - elif model == 'ctrl_neutral1': + elif model in ['ctrl_neutral1', 'ctrl_neutral1.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch', 'channel_0', False, gateway)) - elif model == 'ctrl_ln1': + elif model in ['ctrl_ln1', 'ctrl_ln1.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch LN', 'channel_0', False, gateway)) - elif model == 'ctrl_neutral2': + elif model in ['ctrl_neutral2', 'ctrl_neutral2.aq1']: 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': + elif model in ['ctrl_ln2', 'ctrl_ln2.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Switch LN Left', 'channel_0', @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'Wall Switch LN Right', 'channel_1', False, gateway)) - elif model == '86plug': + elif model in ['86plug', 'ctrl_86plug.aq1']: devices.append(XiaomiGenericSwitch(device, 'Wall Plug', 'status', True, gateway)) add_devices(devices) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 7defc3d3b2b..ae4329a42a1 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -19,10 +19,18 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Miio Switch' +CONF_MODEL = 'model' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODEL): vol.In( + ['chuangmi.plug.v1', + 'qmi.powerstrip.v1', + 'zimi.powerstrip.v2', + 'chuangmi.plug.m1', + 'chuangmi.plug.v2']), }) REQUIREMENTS = ['python-miio==0.3.7'] @@ -43,48 +51,53 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): host = config.get(CONF_HOST) name = config.get(CONF_NAME) token = config.get(CONF_TOKEN) + model = config.get(CONF_MODEL) _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5]) devices = [] - try: - plug = Device(host, token) - device_info = plug.info() - _LOGGER.info("%s %s %s initialized", - device_info.model, - device_info.firmware_version, - device_info.hardware_version) - if device_info.model in ['chuangmi.plug.v1']: - from miio import PlugV1 - plug = PlugV1(host, token) + if model is None: + try: + miio_device = Device(host, token) + device_info = miio_device.info() + model = device_info.model + _LOGGER.info("%s %s %s detected", + model, + device_info.firmware_version, + device_info.hardware_version) + except DeviceException: + raise PlatformNotReady - # The device has two switchable channels (mains and a USB port). - # A switch device per channel will be created. - for channel_usb in [True, False]: - device = ChuangMiPlugV1Switch( - name, plug, device_info, channel_usb) - devices.append(device) + if model in ['chuangmi.plug.v1']: + from miio import PlugV1 + plug = PlugV1(host, token) - elif device_info.model in ['qmi.powerstrip.v1', - 'zimi.powerstrip.v2']: - from miio import PowerStrip - plug = PowerStrip(host, token) - device = XiaomiPowerStripSwitch(name, plug, device_info) + # The device has two switchable channels (mains and a USB port). + # A switch device per channel will be created. + for channel_usb in [True, False]: + device = ChuangMiPlugV1Switch( + name, plug, model, channel_usb) devices.append(device) - elif device_info.model in ['chuangmi.plug.m1', - 'chuangmi.plug.v2']: - from miio import Plug - plug = Plug(host, token) - device = XiaomiPlugGenericSwitch(name, plug, device_info) - devices.append(device) - else: - _LOGGER.error( - 'Unsupported device found! Please create an issue at ' - 'https://github.com/rytilahti/python-miio/issues ' - 'and provide the following data: %s', device_info.model) - except DeviceException: - raise PlatformNotReady + + elif model in ['qmi.powerstrip.v1', + 'zimi.powerstrip.v2']: + from miio import PowerStrip + plug = PowerStrip(host, token) + device = XiaomiPowerStripSwitch(name, plug, model) + devices.append(device) + elif model in ['chuangmi.plug.m1', + 'chuangmi.plug.v2']: + from miio import Plug + plug = Plug(host, token) + device = XiaomiPlugGenericSwitch(name, plug, model) + devices.append(device) + else: + _LOGGER.error( + 'Unsupported device found! Please create an issue at ' + 'https://github.com/rytilahti/python-miio/issues ' + 'and provide the following data: %s', model) + return False async_add_devices(devices, update_before_add=True) @@ -92,17 +105,17 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class XiaomiPlugGenericSwitch(SwitchDevice): """Representation of a Xiaomi Plug Generic.""" - def __init__(self, name, plug, device_info): + def __init__(self, name, plug, model): """Initialize the plug switch.""" self._name = name self._icon = 'mdi:power-socket' - self._device_info = device_info + self._model = model self._plug = plug self._state = None self._state_attrs = { ATTR_TEMPERATURE: None, - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, } self._skip_update = False @@ -191,20 +204,21 @@ class XiaomiPlugGenericSwitch(SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): """Representation of a Xiaomi Power Strip.""" - def __init__(self, name, plug, device_info): + def __init__(self, name, plug, model): """Initialize the plug switch.""" - XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + XiaomiPlugGenericSwitch.__init__(self, name, plug, model) self._state_attrs = { ATTR_TEMPERATURE: None, ATTR_LOAD_POWER: None, - ATTR_MODEL: self._device_info.model, + ATTR_MODEL: self._model, } @asyncio.coroutine @@ -228,17 +242,18 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch, SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): """Representation of a Chuang Mi Plug V1.""" - def __init__(self, name, plug, device_info, channel_usb): + def __init__(self, name, plug, model, channel_usb): """Initialize the plug switch.""" - name = name + ' USB' if channel_usb else name + name = '{} USB'.format(name) if channel_usb else name - XiaomiPlugGenericSwitch.__init__(self, name, plug, device_info) + XiaomiPlugGenericSwitch.__init__(self, name, plug, model) self._channel_usb = channel_usb @asyncio.coroutine @@ -293,4 +308,5 @@ class ChuangMiPlugV1Switch(XiaomiPlugGenericSwitch, SwitchDevice): }) except DeviceException as ex: + self._state = None _LOGGER.error("Got exception while fetching the state: %s", ex) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index c98db2e894e..7de9f1459b1 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -4,7 +4,6 @@ Switches on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/switch.zha/ """ -import asyncio import logging from homeassistant.components.switch import DOMAIN, SwitchDevice @@ -15,19 +14,39 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['zha'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Zigbee Home Automation switches.""" +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Zigbee Home Automation switches.""" discovery_info = zha.get_discovery_info(hass, discovery_info) if discovery_info is None: return - add_devices([Switch(**discovery_info)]) + from zigpy.zcl.clusters.general import OnOff + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await cluster.bind() + await cluster.configure_reporting(0, 0, 600, 1,) + + async_add_devices([Switch(**discovery_info)], update_before_add=True) class Switch(zha.Entity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN + value_attribute = 0 + + def attribute_updated(self, attribute, value): + """Handle attribute update from device.""" + _LOGGER.debug("Attribute updated: %s %s %s", self, attribute, value) + if attribute == self.value_attribute: + self._state = value + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False @property def is_on(self) -> bool: @@ -36,14 +55,18 @@ class Switch(zha.Entity, SwitchDevice): return False return bool(self._state) - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on.""" - yield from self._endpoint.on_off.on() + await self._endpoint.on_off.on() self._state = 1 - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off.""" - yield from self._endpoint.on_off.off() + await self._endpoint.on_off.off() self._state = 0 + + async def async_update(self): + """Retrieve latest state.""" + result = await zha.safe_read(self._endpoint.on_off, + ['on_off']) + self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index d4ac115d9c6..9e5d4cd9665 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -237,13 +237,12 @@ def async_setup(hass, config): _LOGGER.exception("Error setting up platform %s", p_type) return False + bot = initialize_bot(p_config) notify_service = TelegramNotificationService( hass, - p_config.get(CONF_API_KEY), + bot, p_config.get(CONF_ALLOWED_CHAT_IDS), - p_config.get(ATTR_PARSER), - p_config.get(CONF_PROXY_URL), - p_config.get(CONF_PROXY_PARAMS) + p_config.get(ATTR_PARSER) ) @asyncio.coroutine @@ -302,15 +301,28 @@ def async_setup(hass, config): return True +def initialize_bot(p_config): + """Initialize telegram bot with proxy support.""" + from telegram import Bot + from telegram.utils.request import Request + + api_key = p_config.get(CONF_API_KEY) + proxy_url = p_config.get(CONF_PROXY_URL) + proxy_params = p_config.get(CONF_PROXY_PARAMS) + + request = None + if proxy_url is not None: + request = Request(proxy_url=proxy_url, + urllib3_proxy_kwargs=proxy_params) + return Bot(token=api_key, request=request) + + class TelegramNotificationService: """Implement the notification services for the Telegram Bot domain.""" - def __init__(self, hass, api_key, allowed_chat_ids, parser, - proxy_url=None, proxy_params=None): + def __init__(self, hass, bot, allowed_chat_ids, parser): """Initialize the service.""" - from telegram import Bot from telegram.parsemode import ParseMode - from telegram.utils.request import Request self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] @@ -318,11 +330,7 @@ class TelegramNotificationService: self._parsers = {PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN} self._parse_mode = self._parsers.get(parser) - request = None - if proxy_url is not None: - request = Request(proxy_url=proxy_url, - urllib3_proxy_kwargs=proxy_params) - self.bot = Bot(token=api_key, request=request) + self.bot = bot self.hass = hass def _get_msg_ids(self, msg_data, chat_id): diff --git a/homeassistant/components/telegram_bot/broadcast.py b/homeassistant/components/telegram_bot/broadcast.py index 091aab58be8..7e157fdb0c7 100644 --- a/homeassistant/components/telegram_bot/broadcast.py +++ b/homeassistant/components/telegram_bot/broadcast.py @@ -8,8 +8,8 @@ import asyncio import logging from homeassistant.components.telegram_bot import ( + initialize_bot, PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) -from homeassistant.const import CONF_API_KEY _LOGGER = logging.getLogger(__name__) @@ -20,8 +20,9 @@ PLATFORM_SCHEMA = TELEGRAM_PLATFORM_SCHEMA def async_setup_platform(hass, config): """Set up the Telegram broadcast platform.""" # Check the API key works - import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + + bot = initialize_bot(config) + bot_config = yield from hass.async_add_job(bot.getMe) _LOGGER.debug("Telegram broadcast platform setup with bot %s", bot_config['username']) diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index bec239ba1dd..ba8dc54b264 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -13,10 +13,11 @@ from aiohttp.client_exceptions import ClientError from aiohttp.hdrs import CONNECTION, KEEP_ALIVE from homeassistant.components.telegram_bot import ( + initialize_bot, CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA as TELEGRAM_PLATFORM_SCHEMA) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_API_KEY) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -35,8 +36,7 @@ class WrongHttpStatus(Exception): @asyncio.coroutine def async_setup_platform(hass, config): """Set up the Telegram polling platform.""" - import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + bot = initialize_bot(config) pol = TelegramPoll(bot, hass, config[CONF_ALLOWED_CHAT_IDS]) @callback diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 5c293459447..b7dd7ab8269 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -14,9 +14,10 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.const import KEY_REAL_IP from homeassistant.components.telegram_bot import ( - CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA) + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, PLATFORM_SCHEMA, + initialize_bot) from homeassistant.const import ( - CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, + EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED, CONF_URL) import homeassistant.helpers.config_validation as cv @@ -50,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def async_setup_platform(hass, config): """Set up the Telegram webhooks platform.""" import telegram - bot = telegram.Bot(config[CONF_API_KEY]) + bot = initialize_bot(config) current_status = yield from hass.async_add_job(bot.getWebhookInfo) base_url = config.get(CONF_URL, hass.config.api.base_url) diff --git a/homeassistant/components/upcloud.py b/homeassistant/components/upcloud.py new file mode 100644 index 00000000000..40e4ceffed8 --- /dev/null +++ b/homeassistant/components/upcloud.py @@ -0,0 +1,172 @@ +""" +Support for UpCloud. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/upcloud/ +""" +import asyncio +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL, + STATE_ON, STATE_OFF, STATE_PROBLEM) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['upcloud-api==0.4.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_CORE_NUMBER = 'core_number' +ATTR_HOSTNAME = 'hostname' +ATTR_MEMORY_AMOUNT = 'memory_amount' +ATTR_STATE = 'state' +ATTR_TITLE = 'title' +ATTR_UUID = 'uuid' +ATTR_ZONE = 'zone' + +CONF_SERVERS = 'servers' + +DATA_UPCLOUD = 'data_upcloud' +DOMAIN = 'upcloud' + +DEFAULT_COMPONENT_NAME = 'UpCloud {}' +DEFAULT_COMPONENT_DEVICE_CLASS = 'power' + +UPCLOUD_PLATFORMS = ['binary_sensor', 'switch'] + +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_UPCLOUD = "upcloud_update" + +STATE_MAP = { + "started": STATE_ON, + "stopped": STATE_OFF, + "error": STATE_PROBLEM, +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the UpCloud component.""" + import upcloud_api + + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + manager = upcloud_api.CloudManager(username, password) + + try: + manager.authenticate() + hass.data[DATA_UPCLOUD] = UpCloud(manager) + except upcloud_api.UpCloudAPIError: + _LOGGER.error("Authentication failed.") + return False + + def upcloud_update(event_time): + """Call UpCloud to update information.""" + _LOGGER.debug("Updating UpCloud component") + hass.data[DATA_UPCLOUD].update() + dispatcher_send(hass, SIGNAL_UPDATE_UPCLOUD) + + # Call the UpCloud API to refresh data + track_time_interval(hass, upcloud_update, scan_interval) + + return True + + +class UpCloud(object): + """Handle all communication with the UpCloud API.""" + + def __init__(self, manager): + """Initialize the UpCloud connection.""" + self.data = {} + self.manager = manager + + def update(self): + """Update data from UpCloud API.""" + self.data = { + server.uuid: server for server in self.manager.get_servers() + } + + +class UpCloudServerEntity(Entity): + """Entity class for UpCloud servers.""" + + def __init__(self, upcloud, uuid): + """Initialize the UpCloud server entity.""" + self._upcloud = upcloud + self.uuid = uuid + self.data = None + + @property + def name(self): + """Return the name of the component.""" + try: + return DEFAULT_COMPONENT_NAME.format(self.data.title) + except (AttributeError, KeyError, TypeError): + return DEFAULT_COMPONENT_NAME.format(self.uuid) + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_UPCLOUD, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def icon(self): + """Return the icon of this server.""" + return 'mdi:server' if self.is_on else 'mdi:server-off' + + @property + def state(self): + """Return state of the server.""" + try: + return STATE_MAP.get(self.data.state) + except AttributeError: + return None + + @property + def is_on(self): + """Return true if the server is on.""" + return self.state == STATE_ON + + @property + def device_class(self): + """Return the class of this server.""" + return DEFAULT_COMPONENT_DEVICE_CLASS + + @property + def device_state_attributes(self): + """Return the state attributes of the UpCloud server.""" + return { + x: getattr(self.data, x, None) + for x in (ATTR_UUID, ATTR_TITLE, ATTR_HOSTNAME, ATTR_ZONE, + ATTR_STATE, ATTR_CORE_NUMBER, ATTR_MEMORY_AMOUNT) + } + + def update(self): + """Update data of the UpCloud server.""" + self.data = self._upcloud.data.get(self.uuid) diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 55f166c4004..1d4ab5eb7ca 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' ICON = 'mdi:roomba' -PLATFORM = 'xiaomi_miio' +DATA_KEY = 'vacuum.xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -88,8 +88,8 @@ SUPPORT_XIAOMI = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Xiaomi vacuum cleaner robot platform.""" from miio import Vacuum - if PLATFORM not in hass.data: - hass.data[PLATFORM] = {} + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -100,7 +100,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): vacuum = Vacuum(host, token) mirobo = MiroboVacuum(name, vacuum) - hass.data[PLATFORM][host] = mirobo + hass.data[DATA_KEY][host] = mirobo async_add_devices([mirobo], update_before_add=True) @@ -112,10 +112,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != ATTR_ENTITY_ID} entity_ids = service.data.get(ATTR_ENTITY_ID) if entity_ids: - target_vacuums = [vac for vac in hass.data[PLATFORM].values() + target_vacuums = [vac for vac in hass.data[DATA_KEY].values() if vac.entity_id in entity_ids] else: - target_vacuums = hass.data[PLATFORM].values() + target_vacuums = hass.data[DATA_KEY].values() update_tasks = [] for vacuum in target_vacuums: diff --git a/homeassistant/components/velux.py b/homeassistant/components/velux.py index ad541ee9cfe..47daf17f2a9 100644 --- a/homeassistant/components/velux.py +++ b/homeassistant/components/velux.py @@ -5,7 +5,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/velux/ """ import logging -import asyncio import voluptuous as vol @@ -28,13 +27,12 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the velux component.""" from pyvlx import PyVLXException try: hass.data[DATA_VELUX] = VeluxModule(hass, config) - yield from hass.data[DATA_VELUX].async_start() + await hass.data[DATA_VELUX].async_start() except PyVLXException as ex: _LOGGER.exception("Can't connect to velux interface: %s", ex) @@ -58,7 +56,6 @@ class VeluxModule: host=host, password=password) - @asyncio.coroutine - def async_start(self): + async def async_start(self): """Start velux component.""" - yield from self.pyvlx.load_scenes() + await self.pyvlx.load_scenes() diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index acb95c17814..b200d634ba9 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -110,7 +110,7 @@ class WeatherEntity(Entity): ATTR_WEATHER_TEMPERATURE: show_temp( self.hass, self.temperature, self.temperature_unit, self.precision), - ATTR_WEATHER_HUMIDITY: self.humidity, + ATTR_WEATHER_HUMIDITY: round(self.humidity) } ozone = self.ozone diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index b79812a8dce..47ef2c3eace 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -191,8 +191,7 @@ def result_message(iden, result=None): } -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) return True @@ -205,11 +204,10 @@ class WebsocketAPIView(HomeAssistantView): url = URL requires_auth = False - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Handle an incoming websocket connection.""" # pylint: disable=no-self-use - return ActiveConnection(request.app['hass'], request).handle() + return await ActiveConnection(request.app['hass'], request).handle() class ActiveConnection: @@ -233,17 +231,16 @@ class ActiveConnection: """Print an error message.""" _LOGGER.error("WS %s: %s %s", id(self.wsock), message1, message2) - @asyncio.coroutine - def _writer(self): + async def _writer(self): """Write outgoing messages.""" # Exceptions if Socket disconnected or cancelled by connection handler with suppress(RuntimeError, *CANCELLATION_ERRORS): while not self.wsock.closed: - message = yield from self.to_write.get() + message = await self.to_write.get() if message is None: break self.debug("Sending", message) - yield from self.wsock.send_json(message, dumps=JSON_DUMP) + await self.wsock.send_json(message, dumps=JSON_DUMP) @callback def send_message_outside(self, message): @@ -266,12 +263,11 @@ class ActiveConnection: self._handle_task.cancel() self._writer_task.cancel() - @asyncio.coroutine - def handle(self): + async def handle(self): """Handle the websocket connection.""" request = self.request wsock = self.wsock = web.WebSocketResponse(heartbeat=55) - yield from wsock.prepare(request) + await wsock.prepare(request) self.debug("Connected") # Get a reference to current task so we can cancel our connection @@ -294,8 +290,8 @@ class ActiveConnection: authenticated = True else: - yield from self.wsock.send_json(auth_required_message()) - msg = yield from wsock.receive_json() + await self.wsock.send_json(auth_required_message()) + msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) if validate_password(request, msg['api_password']): @@ -303,18 +299,18 @@ class ActiveConnection: else: self.debug("Invalid password") - yield from self.wsock.send_json( + await self.wsock.send_json( auth_invalid_message('Invalid password')) if not authenticated: - yield from process_wrong_login(request) + await process_wrong_login(request) return wsock - yield from self.wsock.send_json(auth_ok_message()) + await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- - msg = yield from wsock.receive_json() + msg = await wsock.receive_json() last_id = 0 while msg: @@ -332,7 +328,7 @@ class ActiveConnection: getattr(self, handler_name)(msg) last_id = cur_id - msg = yield from wsock.receive_json() + msg = await wsock.receive_json() except vol.Invalid as err: error_msg = "Message incorrectly formatted: " @@ -394,11 +390,11 @@ class ActiveConnection: self.to_write.put_nowait(final_message) self.to_write.put_nowait(None) # Make sure all error messages are written before closing - yield from self._writer_task + await self._writer_task except asyncio.QueueFull: self._writer_task.cancel() - yield from wsock.close() + await wsock.close() self.debug("Closed connection") return wsock @@ -410,8 +406,7 @@ class ActiveConnection: """ msg = SUBSCRIBE_EVENTS_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def forward_events(event): + async def forward_events(event): """Forward events to websocket.""" if event.event_type == EVENT_TIME_CHANGED: return @@ -447,10 +442,9 @@ class ActiveConnection: """ msg = CALL_SERVICE_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def call_service_helper(msg): + async def call_service_helper(msg): """Call a service and fire complete message.""" - yield from self.hass.services.async_call( + await self.hass.services.async_call( msg['domain'], msg['service'], msg.get('service_data'), True) self.send_message_outside(result_message(msg['id'])) @@ -473,10 +467,9 @@ class ActiveConnection: """ msg = GET_SERVICES_MESSAGE_SCHEMA(msg) - @asyncio.coroutine - def get_services_helper(msg): + async def get_services_helper(msg): """Get available services and fire complete message.""" - descriptions = yield from async_get_all_descriptions(self.hass) + descriptions = await async_get_all_descriptions(self.hass) self.send_message_outside(result_message(msg['id'], descriptions)) self.hass.async_add_job(get_services_helper(msg)) diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index e5942f97139..b6e04d867fa 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.8.1'] +REQUIREMENTS = ['PyXiaomiGateway==0.8.3'] _LOGGER = logging.getLogger(__name__) @@ -242,7 +242,7 @@ class XiaomiDevice(Entity): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/zabbix.py b/homeassistant/components/zabbix.py index adbf34a474c..ea5a6d85d6b 100644 --- a/homeassistant/components/zabbix.py +++ b/homeassistant/components/zabbix.py @@ -52,8 +52,8 @@ def setup(hass, config): try: zapi.login(username, password) _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version()) - except ZabbixAPIException: - _LOGGER.error("Unable to login to the Zabbix API") + except ZabbixAPIException as login_exception: + _LOGGER.error("Unable to login to the Zabbix API: %s", login_exception) return False hass.data[DOMAIN] = zapi diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9a8c88e6f23..88ca29101ad 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -306,7 +306,7 @@ class Entity(entity.Entity): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 0149bb9287a..21db39d4e76 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -916,7 +916,7 @@ class ZWaveDeviceEntity(ZWaveBaseEntity): @property def unique_id(self): - """Return an unique ID.""" + """Return a unique ID.""" return self._unique_id @property diff --git a/homeassistant/components/zwave/discovery_schemas.py b/homeassistant/components/zwave/discovery_schemas.py index 19f428484f8..d38fbc7079c 100644 --- a/homeassistant/components/zwave/discovery_schemas.py +++ b/homeassistant/components/zwave/discovery_schemas.py @@ -16,6 +16,7 @@ DEFAULT_VALUES_SCHEMA = { DISCOVERY_SCHEMAS = [ {const.DISC_COMPONENT: 'binary_sensor', const.DISC_GENERIC_DEVICE_CLASS: [ + const.GENERIC_TYPE_ENTRY_CONTROL, const.GENERIC_TYPE_SENSOR_ALARM, const.GENERIC_TYPE_SENSOR_BINARY, const.GENERIC_TYPE_SWITCH_BINARY, @@ -173,7 +174,8 @@ DISCOVERY_SCHEMAS = [ const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_ENTRY_CONTROL], const.DISC_SPECIFIC_DEVICE_CLASS: [ const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK, - const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK], + const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK, + const.SPECIFIC_TYPE_SECURE_LOCKBOX], const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{ const.DISC_PRIMARY: { const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_DOOR_LOCK], diff --git a/homeassistant/config.py b/homeassistant/config.py index 6507e2a74f6..5f2c6cf1625 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -41,9 +41,9 @@ VERSION_FILE = '.HA_VERSION' CONFIG_DIR_NAME = '.homeassistant' DATA_CUSTOMIZE = 'hass_customize' -FILE_MIGRATION = [ - ['ios.conf', '.ios.conf'], -] +FILE_MIGRATION = ( + ('ios.conf', '.ios.conf'), +) DEFAULT_CORE_CONFIG = ( # Tuples (attribute, default, auto detect property, description) @@ -260,8 +260,7 @@ def create_default_config(config_dir, detect_location=True): return None -@asyncio.coroutine -def async_hass_config_yaml(hass): +async def async_hass_config_yaml(hass): """Load YAML from a Home Assistant configuration file. This function allow a component inside the asyncio loop to reload its @@ -274,7 +273,7 @@ def async_hass_config_yaml(hass): conf = load_yaml_config_file(path) return conf - conf = yield from hass.async_add_job(_load_hass_yaml_config) + conf = await hass.async_add_job(_load_hass_yaml_config) return conf @@ -305,6 +304,9 @@ def load_yaml_config_file(config_path): _LOGGER.error(msg) raise HomeAssistantError(msg) + # Convert values to dictionaries if they are None + for key, value in conf_dict.items(): + conf_dict[key] = value or {} return conf_dict @@ -346,14 +348,22 @@ def process_ha_config_upgrade(hass): @callback def async_log_exception(ex, domain, config, hass): + """Log an error for configuration validation. + + This method must be run in the event loop. + """ + if hass is not None: + async_notify_setup_error(hass, domain, True) + _LOGGER.error(_format_config_error(ex, domain, config)) + + +@callback +def _format_config_error(ex, domain, config): """Generate log exception for configuration validation. This method must be run in the event loop. """ message = "Invalid config for [{}]: ".format(domain) - if hass is not None: - async_notify_setup_error(hass, domain, True) - if 'extra keys not allowed' in ex.error_message: message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\ .format(ex.path[-1], domain, domain, @@ -370,11 +380,10 @@ def async_log_exception(ex, domain, config, hass): message += ('Please check the docs at ' 'https://home-assistant.io/components/{}/'.format(domain)) - _LOGGER.error(message) + return message -@asyncio.coroutine -def async_process_ha_core_config(hass, config): +async def async_process_ha_core_config(hass, config): """Process the [homeassistant] section from the configuration. This method is a coroutine. @@ -461,7 +470,7 @@ def async_process_ha_core_config(hass, config): # If we miss some of the needed values, auto detect them if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): - info = yield from hass.async_add_job( + info = await hass.async_add_job( loc_util.detect_location_info) if info is None: @@ -487,7 +496,7 @@ def async_process_ha_core_config(hass, config): if hac.elevation is None and hac.latitude is not None and \ hac.longitude is not None: - elevation = yield from hass.async_add_job( + elevation = await hass.async_add_job( loc_util.elevation, hac.latitude, hac.longitude) hac.elevation = elevation discovered.append(('elevation', elevation)) @@ -499,7 +508,7 @@ def async_process_ha_core_config(hass, config): def _log_pkg_error(package, component, config, message): - """Log an error while merging.""" + """Log an error while merging packages.""" message = "Package {} setup failed. Component {} {}".format( package, component, message) @@ -525,7 +534,7 @@ def _identify_config_schema(module): return '', schema -def merge_packages_config(config, packages): +def merge_packages_config(config, packages, _log_pkg_error=_log_pkg_error): """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) @@ -591,7 +600,7 @@ def merge_packages_config(config, packages): def async_process_component_config(hass, config, domain): """Check component configuration and return processed configuration. - Raise a vol.Invalid exception on error. + Returns None on error. This method must be run in the event loop. """ @@ -648,21 +657,20 @@ def async_process_component_config(hass, config, domain): return config -@asyncio.coroutine -def async_check_ha_config_file(hass): +async def async_check_ha_config_file(hass): """Check if Home Assistant configuration file is valid. This method is a coroutine. """ - proc = yield from asyncio.create_subprocess_exec( + proc = await asyncio.create_subprocess_exec( sys.executable, '-m', 'homeassistant', '--script', 'check_config', '--config', hass.config.config_dir, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT, loop=hass.loop) # Wait for the subprocess exit - log, _ = yield from proc.communicate() - exit_code = yield from proc.wait() + log, _ = await proc.communicate() + exit_code = await proc.wait() # Convert to ASCII log = RE_ASCII.sub('', log.decode()) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b5d23d284f..230e48f0cec 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -76,7 +76,7 @@ If the user input passes validation, you can again return one of the three return values. If you want to navigate the user to the next step, return the return value of that step: - return (await self.async_step_account()) + return await self.async_step_account() ### Abort @@ -110,7 +110,7 @@ should follow the same return values as a normal step. If the result of the step is to show a form, the user will be able to continue the flow from the config panel. """ -import asyncio + import logging import os import uuid @@ -126,7 +126,8 @@ _LOGGER = logging.getLogger(__name__) HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ - 'config_entry_example' + 'config_entry_example', + 'hue', ] SOURCE_USER = 'user' @@ -176,14 +177,13 @@ class ConfigEntry: # State of the entry (LOADED, NOT_LOADED) self.state = state - @asyncio.coroutine - def async_setup(self, hass, *, component=None): + async def async_setup(self, hass, *, component=None): """Set up an entry.""" if component is None: component = getattr(hass.components, self.domain) try: - result = yield from component.async_setup_entry(hass, self) + result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): _LOGGER.error('%s.async_config_entry did not return boolean', @@ -199,8 +199,7 @@ class ConfigEntry: else: self.state = ENTRY_STATE_SETUP_ERROR - @asyncio.coroutine - def async_unload(self, hass): + async def async_unload(self, hass): """Unload an entry. Returns if unload is possible and was successful. @@ -213,7 +212,7 @@ class ConfigEntry: return False try: - result = yield from component.async_unload_entry(hass, self) + result = await component.async_unload_entry(hass, self) if not isinstance(result, bool): _LOGGER.error('%s.async_unload_entry did not return boolean', @@ -293,8 +292,7 @@ class ConfigEntries: return list(self._entries) return [entry for entry in self._entries if entry.domain == domain] - @asyncio.coroutine - def async_remove(self, entry_id): + async def async_remove(self, entry_id): """Remove an entry.""" found = None for index, entry in enumerate(self._entries): @@ -308,25 +306,23 @@ class ConfigEntries: entry = self._entries.pop(found) self._async_schedule_save() - unloaded = yield from entry.async_unload(self.hass) + unloaded = await entry.async_unload(self.hass) return { 'require_restart': not unloaded } - @asyncio.coroutine - def async_load(self): + async def async_load(self): """Load the config.""" path = self.hass.config.path(PATH_CONFIG) if not os.path.isfile(path): self._entries = [] return - entries = yield from self.hass.async_add_job(load_json, path) + entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] - @asyncio.coroutine - def _async_add_entry(self, entry): + async def _async_add_entry(self, entry): """Add an entry.""" self._entries.append(entry) self._async_schedule_save() @@ -334,10 +330,10 @@ class ConfigEntries: # Setup entry if entry.domain in self.hass.config.components: # Component already set up, just need to call setup_entry - yield from entry.async_setup(self.hass) + await entry.async_setup(self.hass) else: # Setting up component will also load the entries - yield from async_setup_component( + await async_setup_component( self.hass, entry.domain, self._hass_config) @callback @@ -350,13 +346,12 @@ class ConfigEntries: SAVE_DELAY, self.hass.async_add_job, self._async_save ) - @asyncio.coroutine - def _async_save(self): + async def _async_save(self): """Save the entity registry to a file.""" self._sched_save = None data = [entry.as_dict() for entry in self._entries] - yield from self.hass.async_add_job( + await self.hass.async_add_job( save_json, self.hass.config.path(PATH_CONFIG), data) @@ -379,8 +374,7 @@ class FlowManager: 'source': flow.source, } for flow in self._progress.values()] - @asyncio.coroutine - def async_init(self, domain, *, source=SOURCE_USER, data=None): + async def async_init(self, domain, *, source=SOURCE_USER, data=None): """Start a configuration flow.""" handler = HANDLERS.get(domain) @@ -393,7 +387,7 @@ class FlowManager: raise self.hass.helpers.UnknownHandler # Make sure requirements and dependencies of component are resolved - yield from async_process_deps_reqs( + await async_process_deps_reqs( self.hass, self._hass_config, domain, component) flow_id = uuid.uuid4().hex @@ -408,10 +402,9 @@ class FlowManager: else: step = source - return (yield from self._async_handle_step(flow, step, data)) + return await self._async_handle_step(flow, step, data) - @asyncio.coroutine - def async_configure(self, flow_id, user_input=None): + async def async_configure(self, flow_id, user_input=None): """Start or continue a configuration flow.""" flow = self._progress.get(flow_id) @@ -423,8 +416,8 @@ class FlowManager: if data_schema is not None and user_input is not None: user_input = data_schema(user_input) - return (yield from self._async_handle_step( - flow, step_id, user_input)) + return await self._async_handle_step( + flow, step_id, user_input) @callback def async_abort(self, flow_id): @@ -432,8 +425,7 @@ class FlowManager: if self._progress.pop(flow_id, None) is None: raise UnknownFlow - @asyncio.coroutine - def _async_handle_step(self, flow, step_id, user_input): + async def _async_handle_step(self, flow, step_id, user_input): """Handle a step of a flow.""" method = "async_step_{}".format(step_id) @@ -442,7 +434,7 @@ class FlowManager: raise UnknownStep("Handler {} doesn't support step {}".format( flow.__class__.__name__, step_id)) - result = yield from getattr(flow, method)(user_input) + result = await getattr(flow, method)(user_input) if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT): @@ -466,7 +458,7 @@ class FlowManager: data=result.pop('data'), source=flow.source ) - yield from self._async_add_entry(entry) + await self._async_add_entry(entry) return result diff --git a/homeassistant/const.py b/homeassistant/const.py index a0e9a44ea5b..d8f7e00959c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,12 +1,11 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 64 -PATCH_VERSION = '3' +MINOR_VERSION = 65 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) -REQUIRED_PYTHON_VER = (3, 4, 2) -REQUIRED_PYTHON_VER_WIN = (3, 5, 2) +REQUIRED_PYTHON_VER = (3, 5, 3) # Format for platforms PLATFORM_FORMAT = '{}.{}' diff --git a/homeassistant/core.py b/homeassistant/core.py index 6ffc524c3be..543aba2a0e7 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -164,8 +164,7 @@ class HomeAssistant(object): finally: self.loop.close() - @asyncio.coroutine - def async_start(self): + async def async_start(self): """Finalize startup from inside the event loop. This method is a coroutine. @@ -181,7 +180,7 @@ class HomeAssistant(object): # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() with timeout(TIMEOUT_EVENT_START, loop=self.loop): - yield from self.async_block_till_done() + await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( 'Something is blocking Home Assistant from wrapping up the ' @@ -190,7 +189,7 @@ class HomeAssistant(object): ', '.join(self.config.components)) # Allow automations to set up the start triggers before changing state - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) self.state = CoreState.running _async_create_timer(self) @@ -259,27 +258,25 @@ class HomeAssistant(object): run_coroutine_threadsafe( self.async_block_till_done(), loop=self.loop).result() - @asyncio.coroutine - def async_block_till_done(self): + async def async_block_till_done(self): """Block till all pending work is done.""" # To flush out any call_soon_threadsafe - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) while self._pending_tasks: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: - yield from asyncio.wait(pending, loop=self.loop) + await asyncio.wait(pending, loop=self.loop) else: - yield from asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0, loop=self.loop) def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" fire_coroutine_threadsafe(self.async_stop(), self.loop) - @asyncio.coroutine - def async_stop(self, exit_code=0) -> None: + async def async_stop(self, exit_code=0) -> None: """Stop Home Assistant and shuts down all threads. This method is a coroutine. @@ -288,12 +285,12 @@ class HomeAssistant(object): self.state = CoreState.stopping self.async_track_tasks() self.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - yield from self.async_block_till_done() + await self.async_block_till_done() # stage 2 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - yield from self.async_block_till_done() + await self.async_block_till_done() self.executor.shutdown() self.exit_code = exit_code @@ -912,8 +909,8 @@ class ServiceRegistry(object): self._hass.loop ).result() - @asyncio.coroutine - def async_call(self, domain, service, service_data=None, blocking=False): + async def async_call(self, domain, service, service_data=None, + blocking=False): """ Call a service. @@ -956,14 +953,13 @@ class ServiceRegistry(object): self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data) if blocking: - done, _ = yield from asyncio.wait( + done, _ = await asyncio.wait( [fut], loop=self._hass.loop, timeout=SERVICE_CALL_LIMIT) success = bool(done) unsub() return success - @asyncio.coroutine - def _event_to_service_call(self, event): + async def _event_to_service_call(self, event): """Handle the SERVICE_CALLED events from the EventBus.""" service_data = event.data.get(ATTR_SERVICE_DATA) or {} domain = event.data.get(ATTR_DOMAIN).lower() @@ -1007,7 +1003,7 @@ class ServiceRegistry(object): service_handler.func(service_call) fire_service_executed() elif service_handler.is_coroutinefunction: - yield from service_handler.func(service_call) + await service_handler.func(service_call) fire_service_executed() else: def execute_service(): @@ -1015,7 +1011,7 @@ class ServiceRegistry(object): service_handler.func(service_call) fire_service_executed() - yield from self._hass.async_add_job(execute_service) + await self._hass.async_add_job(execute_service) except Exception: # pylint: disable=broad-except _LOGGER.exception('Error executing service %s', service_call) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 239aaea64a0..72f2214b5e7 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -35,14 +35,7 @@ def async_get_clientsession(hass, verify_ssl=True): key = DATA_CLIENTSESSION_NOTVERIFY if key not in hass.data: - connector = _async_get_connector(hass, verify_ssl) - clientsession = aiohttp.ClientSession( - loop=hass.loop, - connector=connector, - headers={USER_AGENT: SERVER_SOFTWARE} - ) - _async_register_clientsession_shutdown(hass, clientsession) - hass.data[key] = clientsession + hass.data[key] = async_create_clientsession(hass, verify_ssl) return hass.data[key] @@ -106,29 +99,28 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, req.close() -@asyncio.coroutine @bind_hass -def async_aiohttp_proxy_stream(hass, request, stream, content_type, - buffer_size=102400, timeout=10): +async def async_aiohttp_proxy_stream(hass, request, stream, content_type, + buffer_size=102400, timeout=10): """Stream a stream to aiohttp web response.""" response = web.StreamResponse() response.content_type = content_type - yield from response.prepare(request) + await response.prepare(request) try: while True: with async_timeout.timeout(timeout, loop=hass.loop): - data = yield from stream.read(buffer_size) + data = await stream.read(buffer_size) if not data: - yield from response.write_eof() + await response.write_eof() break - response.write(data) + await response.write(data) except (asyncio.TimeoutError, aiohttp.ClientError): # Something went wrong fetching data, close connection gracefully - yield from response.write_eof() + await response.write_eof() except asyncio.CancelledError: # The user closed the connection diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index e32b041ffa2..4b7c58f6e66 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS, CONF_CONDITION, CONF_BELOW, CONF_ABOVE, CONF_TIMEOUT, SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC) -from homeassistant.core import valid_entity_id +from homeassistant.core import valid_entity_id, split_entity_id from homeassistant.exceptions import TemplateError import homeassistant.util.dt as dt_util from homeassistant.util import slugify as util_slugify @@ -36,6 +36,7 @@ latitude = vol.All(vol.Coerce(float), vol.Range(min=-90, max=90), msg='invalid latitude') longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180), msg='invalid longitude') +gps = vol.ExactSequence([latitude, longitude]) sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)) port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) @@ -147,6 +148,29 @@ def entity_ids(value: Union[str, Sequence]) -> Sequence[str]: return [entity_id(ent_id) for ent_id in value] +def entity_domain(domain: str): + """Validate that entity belong to domain.""" + def validate(value: Any) -> str: + """Test if entity domain is domain.""" + ent_domain = entities_domain(domain) + return ent_domain(value)[0] + return validate + + +def entities_domain(domain: str): + """Validate that entities belong to domain.""" + def validate(values: Union[str, Sequence]) -> Sequence[str]: + """Test if entity domain is domain.""" + values = entity_ids(values) + for ent_id in values: + if split_entity_id(ent_id)[0] != domain: + raise vol.Invalid( + "Entity ID '{}' does not belong to domain '{}'" + .format(ent_id, domain)) + return values + return validate + + def enum(enumClass): """Create validator for specified enum.""" return vol.All(vol.In(enumClass.__members__), enumClass.__getitem__) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 04719e89187..f23a49c1851 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -93,7 +93,7 @@ class Entity(object): @property def unique_id(self) -> str: - """Return an unique ID.""" + """Return a unique ID.""" return None @property @@ -332,14 +332,19 @@ class Entity(object): if self.parallel_updates: self.parallel_updates.release() - @asyncio.coroutine - def async_remove(self): + async def async_remove(self): """Remove entity from Home Assistant.""" if self.platform is not None: - yield from self.platform.async_remove_entity(self.entity_id) + await self.platform.async_remove_entity(self.entity_id) else: self.hass.states.async_remove(self.entity_id) + @callback + def async_registry_updated(self, old, new): + """Called when the entity registry has been updated.""" + self.registry_name = new.name + self.async_schedule_update_ha_state() + def __eq__(self, other): """Return the comparison.""" if not isinstance(other, self.__class__): diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 2dcde6fdeda..f086437c10d 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -75,8 +75,7 @@ class EntityComponent(object): """ self.hass.add_job(self.async_setup(config)) - @asyncio.coroutine - def async_setup(self, config): + async def async_setup(self, config): """Set up a full entity component. Loads the platforms from the config and will listen for supported @@ -92,14 +91,13 @@ class EntityComponent(object): tasks.append(self._async_setup_platform(p_type, p_config)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) # Generic discovery listener for loading platform dynamically # Refer to: homeassistant.components.discovery.load_platform() - @asyncio.coroutine - def component_platform_discovered(platform, info): + async def component_platform_discovered(platform, info): """Handle the loading of a platform.""" - yield from self._async_setup_platform(platform, {}, info) + await self._async_setup_platform(platform, {}, info) discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered) @@ -120,11 +118,10 @@ class EntityComponent(object): return [entity for entity in self.entities if entity.available and entity.entity_id in entity_ids] - @asyncio.coroutine - def _async_setup_platform(self, platform_type, platform_config, - discovery_info=None): + async def _async_setup_platform(self, platform_type, platform_config, + discovery_info=None): """Set up a platform for this component.""" - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( self.hass, self.config, self.domain, platform_type) if platform is None: @@ -156,7 +153,7 @@ class EntityComponent(object): else: entity_platform = self._platforms[key] - yield from entity_platform.async_setup( + await entity_platform.async_setup( platform, platform_config, discovery_info) @callback @@ -177,8 +174,7 @@ class EntityComponent(object): visible=False, entity_ids=ids ) - @asyncio.coroutine - def _async_reset(self): + async def _async_reset(self): """Remove entities and reset the entity component to initial values. This method must be run in the event loop. @@ -187,7 +183,7 @@ class EntityComponent(object): in self._platforms.values()] if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self._platforms = { self.domain: self._platforms[self.domain] @@ -197,21 +193,19 @@ class EntityComponent(object): if self.group_name is not None: self.hass.components.group.async_remove(slugify(self.group_name)) - @asyncio.coroutine - def async_remove_entity(self, entity_id): + async def async_remove_entity(self, entity_id): """Remove an entity managed by one of the platforms.""" for platform in self._platforms.values(): if entity_id in platform.entities: - yield from platform.async_remove_entity(entity_id) + await platform.async_remove_entity(entity_id) - @asyncio.coroutine - def async_prepare_reload(self): + async def async_prepare_reload(self): """Prepare reloading this entity component. This method must be run in the event loop. """ try: - conf = yield from \ + conf = await \ conf_util.async_hass_config_yaml(self.hass) except HomeAssistantError as err: self.logger.error(err) @@ -223,5 +217,5 @@ class EntityComponent(object): if conf is None: return None - yield from self._async_reset() + await self._async_reset() return conf diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index e17e178bcfb..d28212a34d1 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -10,12 +10,11 @@ from homeassistant.util.async import ( import homeassistant.util.dt as dt_util from .event import async_track_time_interval, async_track_point_in_time -from .entity_registry import EntityRegistry +from .entity_registry import async_get_registry SLOW_SETUP_WARNING = 10 SLOW_SETUP_MAX_WAIT = 60 PLATFORM_NOT_READY_RETRIES = 10 -DATA_REGISTRY = 'entity_registry' class EntityPlatform(object): @@ -52,9 +51,8 @@ class EntityPlatform(object): self.parallel_updates = asyncio.Semaphore( parallel_updates, loop=hass.loop) - @asyncio.coroutine - def async_setup(self, platform, platform_config, discovery_info=None, - tries=0): + async def async_setup(self, platform, platform_config, discovery_info=None, + tries=0): """Setup the platform.""" logger = self.logger hass = self.hass @@ -79,7 +77,7 @@ class EntityPlatform(object): None, platform.setup_platform, hass, platform_config, self._schedule_add_entities, discovery_info ) - yield from asyncio.wait_for( + await asyncio.wait_for( asyncio.shield(task, loop=hass.loop), SLOW_SETUP_MAX_WAIT, loop=hass.loop) @@ -89,7 +87,7 @@ class EntityPlatform(object): self._tasks.clear() if pending: - yield from asyncio.wait( + await asyncio.wait( pending, loop=self.hass.loop) hass.config.components.add(full_name) @@ -143,8 +141,7 @@ class EntityPlatform(object): self.async_add_entities(list(new_entities), update_before_add), self.hass.loop).result() - @asyncio.coroutine - def async_add_entities(self, new_entities, update_before_add=False): + async def async_add_entities(self, new_entities, update_before_add=False): """Add entities for a single platform async. This method must be run in the event loop. @@ -156,19 +153,14 @@ class EntityPlatform(object): hass = self.hass component_entities = set(hass.states.async_entity_ids(self.domain)) - registry = hass.data.get(DATA_REGISTRY) - - if registry is None: - registry = hass.data[DATA_REGISTRY] = EntityRegistry(hass) - - yield from registry.async_ensure_loaded() + registry = await async_get_registry(hass) tasks = [ self._async_add_entity(entity, update_before_add, component_entities, registry) for entity in new_entities] - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) self.async_entities_added_callback() if self._async_unsub_polling is not None or \ @@ -180,9 +172,8 @@ class EntityPlatform(object): self.hass, self._update_entity_states, self.scan_interval ) - @asyncio.coroutine - def _async_add_entity(self, entity, update_before_add, component_entities, - registry): + async def _async_add_entity(self, entity, update_before_add, + component_entities, registry): """Helper method to add an entity to the platform.""" if entity is None: raise ValueError('Entity cannot be None') @@ -194,7 +185,7 @@ class EntityPlatform(object): # Update properties before we generate the entity_id if update_before_add: try: - yield from entity.async_device_update(warning=False) + await entity.async_device_update(warning=False) except Exception: # pylint: disable=broad-except self.logger.exception( "%s: Error on device update!", self.platform_name) @@ -226,6 +217,7 @@ class EntityPlatform(object): entity.entity_id = entry.entity_id entity.registry_name = entry.name + entry.add_update_listener(entity) # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID @@ -263,12 +255,11 @@ class EntityPlatform(object): component_entities.add(entity.entity_id) if hasattr(entity, 'async_added_to_hass'): - yield from entity.async_added_to_hass() + await entity.async_added_to_hass() - yield from entity.async_update_ha_state() + await entity.async_update_ha_state() - @asyncio.coroutine - def async_reset(self): + async def async_reset(self): """Remove all entities and reset data. This method must be run in the event loop. @@ -279,16 +270,15 @@ class EntityPlatform(object): tasks = [self._async_remove_entity(entity_id) for entity_id in self.entities] - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) if self._async_unsub_polling is not None: self._async_unsub_polling() self._async_unsub_polling = None - @asyncio.coroutine - def async_remove_entity(self, entity_id): + async def async_remove_entity(self, entity_id): """Remove entity id from platform.""" - yield from self._async_remove_entity(entity_id) + await self._async_remove_entity(entity_id) # Clean up polling job if no longer needed if (self._async_unsub_polling is not None and @@ -297,18 +287,16 @@ class EntityPlatform(object): self._async_unsub_polling() self._async_unsub_polling = None - @asyncio.coroutine - def _async_remove_entity(self, entity_id): + async def _async_remove_entity(self, entity_id): """Remove entity id from platform.""" entity = self.entities.pop(entity_id) if hasattr(entity, 'async_will_remove_from_hass'): - yield from entity.async_will_remove_from_hass() + await entity.async_will_remove_from_hass() self.hass.states.async_remove(entity_id) - @asyncio.coroutine - def _update_entity_states(self, now): + async def _update_entity_states(self, now): """Update the states of all the polling entities. To protect from flooding the executor, we will update async entities @@ -323,7 +311,7 @@ class EntityPlatform(object): self.scan_interval) return - with (yield from self._process_updates): + with (await self._process_updates): tasks = [] for entity in self.entities.values(): if not entity.should_poll: @@ -331,4 +319,4 @@ class EntityPlatform(object): tasks.append(entity.async_update_ha_state(True)) if tasks: - yield from asyncio.wait(tasks, loop=self.hass.loop) + await asyncio.wait(tasks, loop=self.hass.loop) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 89719b0b823..b5a9c309119 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,22 +10,25 @@ timer. After initializing, call EntityRegistry.async_ensure_loaded to load the data from disk. """ -import asyncio + from collections import OrderedDict from itertools import chain import logging import os +import weakref import attr from ..core import callback, split_entity_id +from ..loader import bind_hass from ..util import ensure_unique_string, slugify from ..util.yaml import load_yaml, save_yaml PATH_REGISTRY = 'entity_registry.yaml' +DATA_REGISTRY = 'entity_registry' SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) - +_UNDEF = object() DISABLED_HASS = 'hass' DISABLED_USER = 'user' @@ -34,6 +37,8 @@ DISABLED_USER = 'user' class RegistryEntry: """Entity Registry Entry.""" + # pylint: disable=no-member + entity_id = attr.ib(type=str) unique_id = attr.ib(type=str) platform = attr.ib(type=str) @@ -41,17 +46,27 @@ class RegistryEntry: disabled_by = attr.ib( type=str, default=None, validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None))) - domain = attr.ib(type=str, default=None, init=False, repr=False) + update_listeners = attr.ib(type=list, default=attr.Factory(list), + repr=False) + domain = attr.ib(type=str, init=False, repr=False) - def __attrs_post_init__(self): - """Computed properties.""" - object.__setattr__(self, "domain", split_entity_id(self.entity_id)[0]) + @domain.default + def _domain_default(self): + """Compute domain value.""" + return split_entity_id(self.entity_id)[0] @property def disabled(self): """Return if entry is disabled.""" return self.disabled_by is not None + def add_update_listener(self, listener): + """Listen for when entry is updated. + + Listener: Callback function(old_entry, new_entry) + """ + self.update_listeners.append(weakref.ref(listener)) + class EntityRegistry: """Class to hold a registry of entities.""" @@ -102,8 +117,40 @@ class EntityRegistry: self.async_schedule_save() return entity - @asyncio.coroutine - def async_ensure_loaded(self): + @callback + def async_update_entity(self, entity_id, *, name=_UNDEF): + """Update properties of an entity.""" + old = self.entities[entity_id] + + changes = {} + + if name is not _UNDEF and name != old.name: + changes['name'] = name + + if not changes: + return old + + new = self.entities[entity_id] = attr.evolve(old, **changes) + + to_remove = [] + for listener_ref in new.update_listeners: + listener = listener_ref() + if listener is None: + to_remove.append(listener) + else: + try: + listener.async_registry_updated(old, new) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error calling update listener') + + for ref in to_remove: + new.update_listeners.remove(ref) + + self.async_schedule_save() + + return new + + async def async_ensure_loaded(self): """Load the registry from disk.""" if self.entities is not None: return @@ -111,16 +158,15 @@ class EntityRegistry: if self._load_task is None: self._load_task = self.hass.async_add_job(self._async_load) - yield from self._load_task + await self._load_task - @asyncio.coroutine - def _async_load(self): + async def _async_load(self): """Load the entity registry.""" path = self.hass.config.path(PATH_REGISTRY) entities = OrderedDict() if os.path.isfile(path): - data = yield from self.hass.async_add_job(load_yaml, path) + data = await self.hass.async_add_job(load_yaml, path) for entity_id, info in data.items(): entities[entity_id] = RegistryEntry( @@ -144,8 +190,7 @@ class EntityRegistry: SAVE_DELAY, self.hass.async_add_job, self._async_save ) - @asyncio.coroutine - def _async_save(self): + async def _async_save(self): """Save the entity registry to a file.""" self._sched_save = None data = OrderedDict() @@ -154,7 +199,20 @@ class EntityRegistry: data[entry.entity_id] = { 'unique_id': entry.unique_id, 'platform': entry.platform, + 'name': entry.name, } - yield from self.hass.async_add_job( + await self.hass.async_add_job( save_yaml, self.hass.config.path(PATH_REGISTRY), data) + + +@bind_hass +async def async_get_registry(hass) -> EntityRegistry: + """Return entity registry instance.""" + registry = hass.data.get(DATA_REGISTRY) + + if registry is None: + registry = hass.data[DATA_REGISTRY] = EntityRegistry(hass) + + await registry.async_ensure_loaded() + return registry diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index bf2773d32b8..5aa53f17e7b 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -1,10 +1,10 @@ """Module to coordinate user intentions.""" -import asyncio import logging import re import voluptuous as vol +from homeassistant.const import ATTR_SUPPORTED_FEATURES from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -34,6 +34,8 @@ def async_register(hass, handler): if intents is None: intents = hass.data[DATA_KEY] = {} + assert handler.intent_type is not None, 'intent_type cannot be None' + if handler.intent_type in intents: _LOGGER.warning('Intent %s is being overwritten by %s.', handler.intent_type, handler) @@ -41,9 +43,9 @@ def async_register(hass, handler): intents[handler.intent_type] = handler -@asyncio.coroutine @bind_hass -def async_handle(hass, platform, intent_type, slots=None, text_input=None): +async def async_handle(hass, platform, intent_type, slots=None, + text_input=None): """Handle an intent.""" handler = hass.data.get(DATA_KEY, {}).get(intent_type) @@ -54,38 +56,63 @@ def async_handle(hass, platform, intent_type, slots=None, text_input=None): try: _LOGGER.info("Triggering intent handler %s", handler) - result = yield from handler.async_handle(intent) + result = await handler.async_handle(intent) return result except vol.Invalid as err: + _LOGGER.warning('Received invalid slot info for %s: %s', + intent_type, err) raise InvalidSlotInfo( 'Received invalid slot info for {}'.format(intent_type)) from err + except IntentHandleError: + raise except Exception as err: - raise IntentHandleError( + raise IntentUnexpectedError( 'Error handling {}'.format(intent_type)) 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 IntentUnexpectedError(IntentError): + """Unexpected error while handling intent.""" + + +@callback +@bind_hass +def async_match_state(hass, name, states=None): + """Find a state that matches the name.""" + if states is None: + states = hass.states.async_all() + + state = _fuzzymatch(name, states, lambda state: state.name) + + if state is None: + raise IntentHandleError( + 'Unable to find an entity called {}'.format(name)) + + return state + + +@callback +def async_test_feature(state, feature, feature_name): + """Test is state supports a feature.""" + if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: + raise IntentHandleError( + 'Entity {} does not support {}'.format( + state.name, feature_name)) class IntentHandler: @@ -114,8 +141,7 @@ class IntentHandler: return self._slot_schema(slots) - @asyncio.coroutine - def async_handle(self, intent_obj): + async def async_handle(self, intent_obj): """Handle the intent.""" raise NotImplementedError() @@ -124,16 +150,18 @@ class IntentHandler: return '<{} - {}>'.format(self.__class__.__name__, self.intent_type) -def fuzzymatch(name, entities): +def _fuzzymatch(name, items, key): """Fuzzy matching function.""" matches = [] pattern = '.*?'.join(name) regex = re.compile(pattern, re.IGNORECASE) - for entity_id, entity_name in entities.items(): - match = regex.search(entity_name) + for idx, item in enumerate(items): + match = regex.search(key(item)) if match: - matches.append((len(match.group()), match.start(), entity_id)) - return [x for _, _, x in sorted(matches)] + # Add index so we pick first match in case same group and start + matches.append((len(match.group()), match.start(), idx, item)) + + return sorted(matches)[0][3] if matches else None class ServiceIntentHandler(IntentHandler): @@ -143,7 +171,7 @@ class ServiceIntentHandler(IntentHandler): """ slot_schema = { - 'name': cv.string, + vol.Required('name'): cv.string, } def __init__(self, intent_type, domain, service, speech): @@ -153,35 +181,18 @@ class ServiceIntentHandler(IntentHandler): self.service = service self.speech = speech - @asyncio.coroutine - def async_handle(self, intent_obj): + async def async_handle(self, intent_obj): """Handle the hass intent.""" hass = intent_obj.hass slots = self.async_validate_slots(intent_obj.slots) - response = intent_obj.create_response() + state = async_match_state(hass, slots['name']['value']) - name = slots['name']['value'] - entities = {state.entity_id: state.name for state - in hass.states.async_all()} - - matches = fuzzymatch(name, entities) - entity_id = matches[0] if matches else None - _LOGGER.debug("%s matched entity: %s", name, entity_id) + await hass.services.async_call(self.domain, self.service, { + ATTR_ENTITY_ID: state.entity_id + }) response = intent_obj.create_response() - if not entity_id: - response.async_set_speech( - "Could not find entity id matching {}.".format(name)) - _LOGGER.error("Could not find entity id matching %s", name) - return response - - yield from hass.services.async_call( - self.domain, self.service, { - ATTR_ENTITY_ID: entity_id - }) - - response.async_set_speech( - self.speech.format(name)) + response.async_set_speech(self.speech.format(state.name)) return response diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index a2940f06022..aac00b07d7a 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -49,9 +49,8 @@ def _load_restore_cache(hass: HomeAssistant): _LOGGER.debug('Created cache with %s', list(hass.data[DATA_RESTORE_CACHE])) -@asyncio.coroutine @bind_hass -def async_get_last_state(hass, entity_id: str): +async def async_get_last_state(hass, entity_id: str): """Restore state.""" if DATA_RESTORE_CACHE in hass.data: return hass.data[DATA_RESTORE_CACHE].get(entity_id) @@ -66,7 +65,7 @@ def async_get_last_state(hass, entity_id: str): try: with async_timeout.timeout(RECORDER_TIMEOUT, loop=hass.loop): - connected = yield from wait_connection_ready(hass) + connected = await wait_connection_ready(hass) except asyncio.TimeoutError: return None @@ -76,25 +75,24 @@ def async_get_last_state(hass, entity_id: str): if _LOCK not in hass.data: hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) - with (yield from hass.data[_LOCK]): + with (await hass.data[_LOCK]): if DATA_RESTORE_CACHE not in hass.data: - yield from hass.async_add_job( + await hass.async_add_job( _load_restore_cache, hass) return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) -@asyncio.coroutine -def async_restore_state(entity, extract_info): +async def async_restore_state(entity, extract_info): """Call entity.async_restore_state with cached info.""" if entity.hass.state not in (CoreState.starting, CoreState.not_running): _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", entity.entity_id, entity.hass.state) return - state = yield from async_get_last_state(entity.hass, entity.entity_id) + state = await async_get_last_state(entity.hass, entity.entity_id) if not state: return - yield from entity.async_restore_state(**extract_info(state)) + await entity.async_restore_state(**extract_info(state)) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 7a989267572..6530cb62485 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,5 +1,5 @@ """Helpers to execute scripts.""" -import asyncio + import logging from itertools import islice from typing import Optional, Sequence @@ -68,8 +68,7 @@ class Script(): run_coroutine_threadsafe( self.async_run(variables), self.hass.loop).result() - @asyncio.coroutine - def async_run(self, variables: Optional[Sequence] = None) -> None: + async def async_run(self, variables: Optional[Sequence] = None) -> None: """Run script. This method is a coroutine. @@ -151,7 +150,7 @@ class Script(): self._async_fire_event(action, variables) else: - yield from self._async_call_service(action, variables) + await self._async_call_service(action, variables) self._cur = -1 self.last_action = None @@ -172,15 +171,14 @@ class Script(): if self._change_listener: self.hass.async_add_job(self._change_listener) - @asyncio.coroutine - def _async_call_service(self, action, variables): + async def _async_call_service(self, action, variables): """Call the service specified in the action. This method is a coroutine. """ self.last_action = action.get(CONF_ALIAS, 'call service') self._log("Executing step %s" % self.last_action) - yield from service.async_call_from_config( + await service.async_call_from_config( self.hass, action, True, variables, validate_config=False) def _async_fire_event(self, action, variables): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b89b1689c9e..7118cab211a 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,5 +1,4 @@ """Service calling related helpers.""" -import asyncio import logging # pylint: disable=unused-import from typing import Optional # NOQA @@ -36,10 +35,9 @@ def call_from_config(hass, config, blocking=False, variables=None, validate_config), hass.loop).result() -@asyncio.coroutine @bind_hass -def async_call_from_config(hass, config, blocking=False, variables=None, - validate_config=True): +async def async_call_from_config(hass, config, blocking=False, variables=None, + validate_config=True): """Call a service based on a config hash.""" if validate_config: try: @@ -79,7 +77,7 @@ def async_call_from_config(hass, config, blocking=False, variables=None, if CONF_SERVICE_ENTITY_ID in config: service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID] - yield from hass.services.async_call( + await hass.services.async_call( domain, service_name, service_data, blocking) @@ -115,9 +113,8 @@ def extract_entity_ids(hass, service_call, expand_group=True): return service_ent_id -@asyncio.coroutine @bind_hass -def async_get_all_descriptions(hass): +async def async_get_all_descriptions(hass): """Return descriptions (i.e. user documentation) for all service calls.""" if SERVICE_DESCRIPTION_CACHE not in hass.data: hass.data[SERVICE_DESCRIPTION_CACHE] = {} @@ -156,7 +153,7 @@ def async_get_all_descriptions(hass): break if missing: - loaded = yield from hass.async_add_job(load_services_files, missing) + loaded = await hass.async_add_job(load_services_files, missing) # Build response catch_all_yaml_file = domain_yaml_file(ha.DOMAIN) diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index 255f760ebff..6be0dbae914 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -130,9 +130,8 @@ def reproduce_state(hass, states, blocking=False): async_reproduce_state(hass, states, blocking), hass.loop).result() -@asyncio.coroutine @bind_hass -def async_reproduce_state(hass, states, blocking=False): +async def async_reproduce_state(hass, states, blocking=False): """Reproduce given state.""" if isinstance(states, State): states = [states] @@ -193,16 +192,15 @@ def async_reproduce_state(hass, states, blocking=False): hass.services.async_call(service_domain, service, data, blocking) ) - @asyncio.coroutine - def async_handle_service_calls(coro_list): + async def async_handle_service_calls(coro_list): """Handle service calls by domain sequence.""" for coro in coro_list: - yield from coro + await coro execute_tasks = [async_handle_service_calls(coro_list) for coro_list in domain_tasks.values()] if execute_tasks: - yield from asyncio.wait(execute_tasks, loop=hass.loop) + await asyncio.wait(execute_tasks, loop=hass.loop) def state_as_number(state): diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index a4626c33210..e4c985d5cfb 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -3,11 +3,12 @@ from numbers import Number from homeassistant.core import HomeAssistant from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS def display_temp(hass: HomeAssistant, temperature: float, unit: str, precision: float) -> float: - """Convert temperature into preferred units for display purposes.""" + """Convert temperature into preferred units/precision for display.""" temperature_unit = unit ha_unit = hass.config.units.temperature_unit @@ -25,9 +26,12 @@ def display_temp(hass: HomeAssistant, temperature: float, unit: str, temperature, temperature_unit, ha_unit) # Round in the units appropriate - if precision == 0.5: - return round(temperature * 2) / 2.0 - elif precision == 0.1: - return round(temperature, 1) + if precision == PRECISION_HALVES: + temperature = round(temperature * 2) / 2.0 + elif precision == PRECISION_TENTHS: + temperature = round(temperature, 1) # Integer as a fall back (PRECISION_WHOLE) - return round(temperature) + else: + temperature = round(temperature) + + return temperature diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py new file mode 100644 index 00000000000..9d1773de4d2 --- /dev/null +++ b/homeassistant/helpers/translation.py @@ -0,0 +1,126 @@ +"""Translation string lookup helpers.""" +import logging +# pylint: disable=unused-import +from typing import Optional # NOQA +from os import path + +from homeassistant.loader import get_component, bind_hass +from homeassistant.util.json import load_json + +_LOGGER = logging.getLogger(__name__) + +TRANSLATION_STRING_CACHE = 'translation_string_cache' + + +def recursive_flatten(prefix, data): + """Return a flattened representation of dict data.""" + output = {} + for key, value in data.items(): + if isinstance(value, dict): + output.update( + recursive_flatten('{}{}.'.format(prefix, key), value)) + else: + output['{}{}'.format(prefix, key)] = value + return output + + +def flatten(data): + """Return a flattened representation of dict data.""" + return recursive_flatten('', data) + + +def component_translation_file(component, language): + """Return the translation json file location for a component.""" + if '.' in component: + name = component.split('.', 1)[1] + else: + name = component + + module = get_component(component) + component_path = path.dirname(module.__file__) + + # If loading translations for the package root, (__init__.py), the + # prefix should be skipped. + if module.__name__ == module.__package__: + filename = '{}.json'.format(language) + else: + filename = '{}.{}.json'.format(name, language) + + return path.join(component_path, '.translations', filename) + + +def load_translations_files(translation_files): + """Load and parse translation.json files.""" + loaded = {} + for component, translation_file in translation_files.items(): + loaded[component] = load_json(translation_file) + + return loaded + + +def build_resources(translation_cache, components): + """Build the resources response for the given components.""" + # Build response + resources = {} + for component in components: + if '.' not in component: + domain = component + else: + domain = component.split('.', 1)[0] + + if domain not in resources: + resources[domain] = {} + + # Add the translations for this component to the domain resources. + # Since clients cannot determine which platform an entity belongs to, + # all translations for a domain will be returned together. + resources[domain].update(translation_cache[component]) + + return resources + + +@bind_hass +async def async_get_component_resources(hass, language): + """Return translation resources for all components.""" + if TRANSLATION_STRING_CACHE not in hass.data: + hass.data[TRANSLATION_STRING_CACHE] = {} + if language not in hass.data[TRANSLATION_STRING_CACHE]: + hass.data[TRANSLATION_STRING_CACHE][language] = {} + translation_cache = hass.data[TRANSLATION_STRING_CACHE][language] + + # Get the set of components + components = hass.config.components + + # Calculate the missing components + missing_components = components - set(translation_cache) + missing_files = {} + for component in missing_components: + missing_files[component] = component_translation_file( + component, language) + + # Load missing files + if missing_files: + loaded_translations = await hass.async_add_job( + load_translations_files, missing_files) + + # Update cache + for component, translation_data in loaded_translations.items(): + translation_cache[component] = translation_data + + resources = build_resources(translation_cache, components) + + # Return the component translations resources under the 'component' + # translation namespace + return flatten({'component': resources}) + + +@bind_hass +async def async_get_translations(hass, language): + """Return all backend translations.""" + resources = await async_get_component_resources(hass, language) + if language != 'en': + # Fetch the English resources, as a fallback for missing keys + base_resources = await async_get_component_resources(hass, 'en') + resources = {**base_resources, **resources} + + return resources diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index d0feab414da..3919d896fd1 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -8,6 +8,7 @@ import homeassistant.core GPSType = Tuple[float, float] ConfigType = Dict[str, Any] HomeAssistantType = homeassistant.core.HomeAssistant +ServiceDataType = Dict[str, Any] # Custom type for recorder Queries QueryType = Any diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b6a5f09330..16b8815e5cf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,10 +5,8 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==2.3.10 -yarl==1.1.0 +aiohttp==3.0.6 async_timeout==2.0.0 -chardet==3.0.4 astral==1.5 certifi>=2017.4.17 attrs==17.4.0 diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index aaf83870147..753947a2c12 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -11,8 +11,7 @@ CONSTRAINT_FILE = 'package_constraints.txt' _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_process_requirements(hass, name, requirements): +async def async_process_requirements(hass, name, requirements): """Install the requirements for a component or platform. This method is a coroutine. @@ -24,9 +23,9 @@ def async_process_requirements(hass, name, requirements): pip_install = partial(pkg_util.install_package, **pip_kwargs(hass.config.config_dir)) - with (yield from pip_lock): + async with pip_lock: for req in requirements: - ret = yield from hass.async_add_job(pip_install, req) + ret = await hass.async_add_job(pip_install, req) if not ret: _LOGGER.error("Not initializing %s because could not install " "requirement %s", name, req) @@ -40,6 +39,6 @@ def pip_kwargs(config_dir): kwargs = { 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) } - if not pkg_util.running_under_virtualenv(): + if not pkg_util.is_virtual_env(): kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 834334b8a90..331b9992627 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -50,8 +50,7 @@ def benchmark(func): @benchmark -@asyncio.coroutine -def async_million_events(hass): +async def async_million_events(hass): """Run a million events.""" count = 0 event_name = 'benchmark_event' @@ -73,15 +72,14 @@ def async_million_events(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start @benchmark -@asyncio.coroutine # pylint: disable=invalid-name -def async_million_time_changed_helper(hass): +async def async_million_time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 event = asyncio.Event(loop=hass.loop) @@ -105,15 +103,14 @@ def async_million_time_changed_helper(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start @benchmark -@asyncio.coroutine # pylint: disable=invalid-name -def async_million_state_changed_helper(hass): +async def async_million_state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 entity_id = 'light.kitchen' @@ -141,7 +138,7 @@ def async_million_state_changed_helper(hass): start = timer() - yield from event.wait() + await event.wait() return timer() - start diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index ec55b1d70c5..4e80b3c6536 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -1,16 +1,23 @@ -"""Script to ensure a configuration file exists.""" -import asyncio +"""Script to check the configuration file.""" + import argparse import logging import os -from collections import OrderedDict +from collections import OrderedDict, namedtuple from glob import glob from platform import system from unittest.mock import patch +import attr from typing import Dict, List, Sequence +import voluptuous as vol -from homeassistant import bootstrap, loader, setup, config as config_util +from homeassistant import bootstrap, core, loader +from homeassistant.config import ( + get_default_config_dir, CONF_CORE, CORE_CONFIG_SCHEMA, + CONF_PACKAGES, merge_packages_config, _format_config_error, + find_config_file, load_yaml_config_file, get_component, + extract_domain_configs, config_per_platform, get_platform) import homeassistant.util.yaml as yaml from homeassistant.exceptions import HomeAssistantError @@ -23,35 +30,18 @@ _LOGGER = logging.getLogger(__name__) MOCKS = { 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml), 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml), - 'get': ("homeassistant.loader.get_component", loader.get_component), 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml), - 'except': ("homeassistant.config.async_log_exception", - config_util.async_log_exception), - 'package_error': ("homeassistant.config._log_pkg_error", - config_util._log_pkg_error), - 'logger_exception': ("homeassistant.setup._LOGGER.error", - setup._LOGGER.error), - 'logger_exception_bootstrap': ("homeassistant.bootstrap._LOGGER.error", - bootstrap._LOGGER.error), } SILENCE = ( - 'homeassistant.bootstrap.async_enable_logging', - 'homeassistant.bootstrap.clear_secret_cache', - 'homeassistant.bootstrap.async_register_signal_handling', - 'homeassistant.config.process_ha_config_upgrade', + 'homeassistant.scripts.check_config.yaml.clear_secret_cache', ) + PATCHES = {} C_HEAD = 'bold' ERROR_STR = 'General Errors' -@asyncio.coroutine -def mock_coro(*args): - """Coroutine that returns None.""" - return None - - def color(the_color, *args, reset=None): """Color helper.""" from colorlog.escape_codes import escape_codes, parse_colors @@ -73,11 +63,11 @@ def run(script_args: List) -> int: '--script', choices=['check_config']) parser.add_argument( '-c', '--config', - default=config_util.get_default_config_dir(), + default=get_default_config_dir(), help="Directory that contains the Home Assistant configuration") parser.add_argument( - '-i', '--info', - default=None, + '-i', '--info', nargs='?', + default=None, const='all', help="Show a portion of the config") parser.add_argument( '-f', '--files', @@ -88,21 +78,20 @@ def run(script_args: List) -> int: action='store_true', help="Show secret information") - args = parser.parse_args() + args, unknown = parser.parse_known_args() + if unknown: + print(color('red', "Unknown arguments:", ', '.join(unknown))) config_dir = os.path.join(os.getcwd(), args.config) - config_path = os.path.join(config_dir, 'configuration.yaml') - if not os.path.isfile(config_path): - print('Config does not exist:', config_path) - return 1 print(color('bold', "Testing configuration at", config_dir)) + res = check(config_dir, args.secrets) + domain_info = [] if args.info: domain_info = args.info.split(',') - res = check(config_path) if args.files: print(color(C_HEAD, 'yaml files'), '(used /', color('red', 'not used') + ')') @@ -157,60 +146,23 @@ def run(script_args: List) -> int: return len(res['except']) -def check(config_path): +def check(config_dir, secrets=False): """Perform a check by mocking hass load functions.""" - logging.getLogger('homeassistant.core').setLevel(logging.WARNING) - logging.getLogger('homeassistant.loader').setLevel(logging.WARNING) - logging.getLogger('homeassistant.setup').setLevel(logging.WARNING) - logging.getLogger('homeassistant.bootstrap').setLevel(logging.ERROR) - logging.getLogger('homeassistant.util.yaml').setLevel(logging.INFO) + logging.getLogger('homeassistant.loader').setLevel(logging.CRITICAL) res = { 'yaml_files': OrderedDict(), # yaml_files loaded 'secrets': OrderedDict(), # secret cache and secrets loaded 'except': OrderedDict(), # exceptions raised (with config) - 'components': OrderedDict(), # successful components - 'secret_cache': OrderedDict(), + 'components': None, # successful components + 'secret_cache': None, } # pylint: disable=unused-variable def mock_load(filename): - """Mock hass.util.load_yaml to save config files.""" + """Mock hass.util.load_yaml to save config file names.""" res['yaml_files'][filename] = True return MOCKS['load'][1](filename) - # pylint: disable=unused-variable - def mock_get(comp_name): - """Mock hass.loader.get_component to replace setup & setup_platform.""" - @asyncio.coroutine - def mock_async_setup(*args): - """Mock setup, only record the component name & config.""" - assert comp_name not in res['components'], \ - "Components should contain a list of platforms" - res['components'][comp_name] = args[1].get(comp_name) - return True - module = MOCKS['get'][1](comp_name) - - if module is None: - # Ensure list - msg = '{} not found: {}'.format( - 'Platform' if '.' in comp_name else 'Component', comp_name) - res['except'].setdefault(ERROR_STR, []).append(msg) - return None - - # Test if platform/component and overwrite setup - if '.' in comp_name: - module.async_setup_platform = mock_async_setup - - if hasattr(module, 'setup_platform'): - del module.setup_platform - else: - module.async_setup = mock_async_setup - - if hasattr(module, 'setup'): - del module.setup - - return module - # pylint: disable=unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" @@ -221,37 +173,14 @@ def check(config_path): res['secrets'][node.value] = val return val - def mock_except(ex, domain, config, # pylint: disable=unused-variable - hass=None): - """Mock config.log_exception.""" - MOCKS['except'][1](ex, domain, config, hass) - res['except'][domain] = config.get(domain, config) - - def mock_package_error( # pylint: disable=unused-variable - package, component, config, message): - """Mock config_util._log_pkg_error.""" - MOCKS['package_error'][1](package, component, config, message) - - pkg_key = 'homeassistant.packages.{}'.format(package) - res['except'][pkg_key] = config.get('homeassistant', {}) \ - .get('packages', {}).get(package) - - def mock_logger_exception(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception'][1](msg, *params) - - def mock_logger_exception_bootstrap(msg, *params): - """Log logger.exceptions.""" - res['except'].setdefault(ERROR_STR, []).append(msg % params) - MOCKS['logger_exception_bootstrap'][1](msg, *params) - # Patches to skip functions for sil in SILENCE: - PATCHES[sil] = patch(sil, return_value=mock_coro()) + PATCHES[sil] = patch(sil) # Patches with local mock functions for key, val in MOCKS.items(): + if not secrets and key == 'secrets': + continue # The * in the key is removed to find the mock_function (side_effect) # This allows us to use one side_effect to patch multiple locations mock_function = locals()['mock_' + key.replace('*', '')] @@ -260,22 +189,42 @@ def check(config_path): # Start all patches for pat in PATCHES.values(): pat.start() - # Ensure !secrets point to the patched function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + + if secrets: + # Ensure !secrets point to the patched function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) try: - with patch('homeassistant.util.logging.AsyncHandler._process'): - bootstrap.from_config_file(config_path, skip_pip=True) - res['secret_cache'] = dict(yaml.__SECRET_CACHE) + class HassConfig(): + """Hass object with config.""" + + def __init__(self, conf_dir): + """Init the config_dir.""" + self.config = core.Config() + self.config.config_dir = conf_dir + + loader.prepare(HassConfig(config_dir)) + + res['components'] = check_ha_config_file(config_dir) + + res['secret_cache'] = OrderedDict(yaml.__SECRET_CACHE) + + for err in res['components'].errors: + domain = err.domain or ERROR_STR + res['except'].setdefault(domain, []).append(err.message) + if err.config: + res['except'].setdefault(domain, []).append(err.config) + except Exception as err: # pylint: disable=broad-except print(color('red', 'Fatal error while loading config:'), str(err)) - res['except'].setdefault(ERROR_STR, []).append(err) + res['except'].setdefault(ERROR_STR, []).append(str(err)) finally: # Stop all patches for pat in PATCHES.values(): pat.stop() - # Ensure !secrets point to the original function - yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) + if secrets: + # Ensure !secrets point to the original function + yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml) bootstrap.clear_secret_cache() return res @@ -317,3 +266,125 @@ def dump_dict(layer, indent_count=3, listi=False, **kwargs): dump_dict(i, indent_count + 2, True) else: print(' ', indent_str, i) + + +CheckConfigError = namedtuple( # pylint: disable=invalid-name + 'CheckConfigError', "message domain config") + + +@attr.s +class HomeAssistantConfig(OrderedDict): + """Configuration result with errors attribute.""" + + errors = attr.ib(default=attr.Factory(list)) + + def add_error(self, message, domain=None, config=None): + """Add a single error.""" + self.errors.append(CheckConfigError(str(message), domain, config)) + return self + + +def check_ha_config_file(config_dir): + """Check if Home Assistant configuration file is valid.""" + result = HomeAssistantConfig() + + def _pack_error(package, component, config, message): + """Handle errors from packages: _log_pkg_error.""" + message = "Package {} setup failed. Component {} {}".format( + package, component, message) + domain = 'homeassistant.packages.{}.{}'.format(package, component) + pack_config = core_config[CONF_PACKAGES].get(package, config) + result.add_error(message, domain, pack_config) + + def _comp_error(ex, domain, config): + """Handle errors from components: async_log_exception.""" + result.add_error( + _format_config_error(ex, domain, config), domain, config) + + # Load configuration.yaml + try: + config_path = find_config_file(config_dir) + if not config_path: + return result.add_error("File configuration.yaml not found.") + config = load_yaml_config_file(config_path) + except HomeAssistantError as err: + return result.add_error(err) + finally: + yaml.clear_secret_cache() + + # Extract and validate core [homeassistant] config + try: + core_config = config.pop(CONF_CORE, {}) + core_config = CORE_CONFIG_SCHEMA(core_config) + result[CONF_CORE] = core_config + except vol.Invalid as err: + result.add_error(err, CONF_CORE, core_config) + core_config = {} + + # Merge packages + merge_packages_config( + config, core_config.get(CONF_PACKAGES, {}), _pack_error) + del core_config[CONF_PACKAGES] + + # Filter out repeating config sections + components = set(key.split(' ')[0] for key in config.keys()) + + # Process and validate config + for domain in components: + component = get_component(domain) + if not component: + result.add_error("Component not found: {}".format(domain)) + continue + + if hasattr(component, 'CONFIG_SCHEMA'): + try: + config = component.CONFIG_SCHEMA(config) + result[domain] = config[domain] + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + if not hasattr(component, 'PLATFORM_SCHEMA'): + continue + + platforms = [] + for p_name, p_config in config_per_platform(config, domain): + # Validate component specific platform schema + try: + p_validated = component.PLATFORM_SCHEMA(p_config) + except vol.Invalid as ex: + _comp_error(ex, domain, config) + continue + + # Not all platform components follow same pattern for platforms + # So if p_name is None we are not going to validate platform + # (the automation component is one of them) + if p_name is None: + platforms.append(p_validated) + continue + + platform = get_platform(domain, p_name) + + if platform is None: + result.add_error( + "Platform not found: {}.{}".format(domain, p_name)) + continue + + # Validate platform specific schema + if hasattr(platform, 'PLATFORM_SCHEMA'): + # pylint: disable=no-member + try: + p_validated = platform.PLATFORM_SCHEMA(p_validated) + except vol.Invalid as ex: + _comp_error( + ex, '{}.{}'.format(domain, p_name), p_validated) + continue + + platforms.append(p_validated) + + # Remove config for current component and add validated config back in. + for filter_comp in extract_domain_configs(config, domain): + del config[filter_comp] + result[domain] = platforms + + return result diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5a8681e82fd..5be1547242e 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -30,9 +30,8 @@ def setup_component(hass: core.HomeAssistant, domain: str, async_setup_component(hass, domain, config), loop=hass.loop).result() -@asyncio.coroutine -def async_setup_component(hass: core.HomeAssistant, domain: str, - config: Optional[Dict] = None) -> bool: +async def async_setup_component(hass: core.HomeAssistant, domain: str, + config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies. This method is a coroutine. @@ -43,7 +42,7 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: - return (yield from setup_tasks[domain]) + return await setup_tasks[domain] if config is None: config = {} @@ -54,11 +53,10 @@ def async_setup_component(hass: core.HomeAssistant, domain: str, task = setup_tasks[domain] = hass.async_add_job( _async_setup_component(hass, domain, config)) - return (yield from task) + return await task -@asyncio.coroutine -def _async_process_dependencies(hass, config, name, dependencies): +async def _async_process_dependencies(hass, config, name, dependencies): """Ensure all dependencies are set up.""" blacklisted = [dep for dep in dependencies if dep in loader.DEPENDENCY_BLACKLIST] @@ -75,7 +73,7 @@ def _async_process_dependencies(hass, config, name, dependencies): if not tasks: return True - results = yield from asyncio.gather(*tasks, loop=hass.loop) + results = await asyncio.gather(*tasks, loop=hass.loop) failed = [dependencies[idx] for idx, res in enumerate(results) if not res] @@ -89,9 +87,8 @@ def _async_process_dependencies(hass, config, name, dependencies): return True -@asyncio.coroutine -def _async_setup_component(hass: core.HomeAssistant, - domain: str, config) -> bool: +async def _async_setup_component(hass: core.HomeAssistant, + domain: str, config) -> bool: """Set up a component for Home Assistant. This method is a coroutine. @@ -123,7 +120,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False try: - yield from async_process_deps_reqs(hass, config, domain, component) + await async_process_deps_reqs(hass, config, domain, component) except HomeAssistantError as err: log_error(str(err)) return False @@ -142,9 +139,9 @@ def _async_setup_component(hass: core.HomeAssistant, try: if hasattr(component, 'async_setup'): - result = yield from component.async_setup(hass, processed_config) + result = await component.async_setup(hass, processed_config) else: - result = yield from hass.async_add_job( + result = await hass.async_add_job( component.setup, hass, processed_config) except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) @@ -166,7 +163,7 @@ def _async_setup_component(hass: core.HomeAssistant, return False for entry in hass.config_entries.async_entries(domain): - yield from entry.async_setup(hass, component=component) + await entry.async_setup(hass, component=component) hass.config.components.add(component.DOMAIN) @@ -181,9 +178,8 @@ def _async_setup_component(hass: core.HomeAssistant, return True -@asyncio.coroutine -def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, - platform_name: str) \ +async def async_prepare_setup_platform(hass: core.HomeAssistant, config, + domain: str, platform_name: str) \ -> Optional[ModuleType]: """Load a platform and makes sure dependencies are setup. @@ -209,7 +205,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform try: - yield from async_process_deps_reqs( + await async_process_deps_reqs( hass, config, platform_path, platform) except HomeAssistantError as err: log_error(str(err)) @@ -218,8 +214,7 @@ def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str, return platform -@asyncio.coroutine -def async_process_deps_reqs(hass, config, name, module): +async def async_process_deps_reqs(hass, config, name, module): """Process all dependencies and requirements for a module. Module is a Python module of either a component or platform. @@ -232,14 +227,14 @@ def async_process_deps_reqs(hass, config, name, module): return if hasattr(module, 'DEPENDENCIES'): - dep_success = yield from _async_process_dependencies( + dep_success = await _async_process_dependencies( hass, config, name, module.DEPENDENCIES) if not dep_success: raise HomeAssistantError("Could not setup all dependencies.") if not hass.config.skip_pip and hasattr(module, 'REQUIREMENTS'): - req_success = yield from requirements.async_process_requirements( + req_success = await requirements.async_process_requirements( hass, name, module.REQUIREMENTS) if not req_success: diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 089e1e733ed..70863a0ab90 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -1,12 +1,9 @@ """Color util methods.""" -import logging import math import colorsys from typing import Tuple -_LOGGER = logging.getLogger(__name__) - # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 # names do not have spaces in them so that we can compare against @@ -171,8 +168,7 @@ def color_name_to_rgb(color_name): # spaces in it as well for matching purposes hex_value = COLORS.get(color_name.replace(' ', '').lower()) if not hex_value: - _LOGGER.error('unknown color supplied %s default to white', color_name) - hex_value = COLORS['white'] + raise ValueError('Unknown color') return hex_value @@ -300,16 +296,26 @@ def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: # pylint: disable=invalid-sequence-index -def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[int, int, int]: - """Convert an rgb color to its hsv representation.""" +def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: + """Convert an rgb color to its hsv representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ fHSV = colorsys.rgb_to_hsv(iR/255.0, iG/255.0, iB/255.0) - return (int(fHSV[0]*65536), int(fHSV[1]*255), int(fHSV[2]*255)) + return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3) # pylint: disable=invalid-sequence-index -def color_hsv_to_RGB(iH: int, iS: int, iV: int) -> Tuple[int, int, int]: - """Convert an hsv color into its rgb representation.""" - fRGB = colorsys.hsv_to_rgb(iH/65536, iS/255, iV/255) +def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: + """Convert an hsv color into its rgb representation. + + Hue is scaled 0-360 + Sat is scaled 0-100 + Val is scaled 0-100 + """ + fRGB = colorsys.hsv_to_rgb(iH/360, iS/100, iV/100) return (int(fRGB[0]*255), int(fRGB[1]*255), int(fRGB[2]*255)) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 7a326c34f15..b2577ff6be6 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -32,13 +32,13 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ return {} if default is _UNDEFINED else default -def save_json(filename: str, config: Union[List, Dict]): +def save_json(filename: str, data: Union[List, Dict]): """Save JSON data to a file. Returns True on success. """ try: - data = json.dumps(config, sort_keys=True, indent=4) + data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: fdesc.write(data) return True diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index e8149a85262..a2f707c54f5 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -7,7 +7,6 @@ import sys import threading from urllib.parse import urlparse -from pip.locations import running_under_virtualenv from typing import Optional import pkg_resources @@ -17,6 +16,13 @@ _LOGGER = logging.getLogger(__name__) INSTALL_LOCK = threading.Lock() +def is_virtual_env(): + """Return if we run in a virtual environtment.""" + # Check supports venv && virtualenv + return (getattr(sys, 'base_prefix', sys.prefix) != sys.prefix or + hasattr(sys, 'real_prefix')) + + def install_package(package: str, upgrade: bool = True, target: Optional[str] = None, constraints: Optional[str] = None) -> bool: @@ -37,7 +43,7 @@ def install_package(package: str, upgrade: bool = True, if constraints is not None: args += ['--constraint', constraints] if target: - assert not running_under_virtualenv() + assert not is_virtual_env() # This only works if not running in venv args += ['--user'] env['PYTHONUSERBASE'] = os.path.abspath(target) @@ -88,17 +94,17 @@ def get_user_site(deps_dir: str) -> str: return lib_dir -@asyncio.coroutine -def async_get_user_site(deps_dir: str, loop: asyncio.AbstractEventLoop) -> str: +async 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( + process = await 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() + stdout, _ = await process.communicate() lib_dir = stdout.decode().strip() return lib_dir diff --git a/requirements_all.txt b/requirements_all.txt index 230a13a9ca7..c233e528403 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,10 +6,8 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==2.3.10 -yarl==1.1.0 +aiohttp==3.0.6 async_timeout==2.0.0 -chardet==3.0.4 astral==1.5 certifi>=2017.4.17 attrs==17.4.0 @@ -24,13 +22,13 @@ attrs==17.4.0 DoorBirdPy==0.1.2 # homeassistant.components.homekit -HAP-python==1.1.5 +HAP-python==1.1.7 # homeassistant.components.isy994 PyISY==1.1.0 # homeassistant.components.notify.html5 -PyJWT==1.5.3 +PyJWT==1.6.0 # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 @@ -39,7 +37,7 @@ PyMVGLive==1.1.4 PyMata==2.14 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.8.1 +PyXiaomiGateway==0.8.3 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -75,6 +73,9 @@ aiodns==1.1.1 # homeassistant.components.http aiohttp_cors==0.6.0 +# homeassistant.components.hue +aiohue==0.3.0 + # homeassistant.components.sensor.imap aioimaplib==0.7.13 @@ -175,6 +176,9 @@ caldav==0.5.0 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.coinbase +coinbase==2.1.0 + # homeassistant.components.sensor.coinmarketcap coinmarketcap==4.2.1 @@ -349,7 +353,7 @@ hipnotify==1.0.8 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180227.0 +home-assistant-frontend==20180309.0 # homeassistant.components.camera.onvif http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a @@ -363,9 +367,6 @@ httplib2==0.10.3 # homeassistant.components.media_player.braviatv https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 -# homeassistant.components.coinbase -https://github.com/balloob/coinbase-python/archive/3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1 - # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink https://github.com/balloob/python-broadlink/archive/3580ff2eaccd267846f14246d6ede6e30671f7c6.zip#broadlink==0.5.1 @@ -382,9 +383,6 @@ https://github.com/jabesq/pybotvac/archive/v0.0.5.zip#pybotvac==0.0.5 # homeassistant.components.switch.anel_pwrctrl https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 -# homeassistant.components.switch.edimax -https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1 - # homeassistant.components.sensor.gtfs https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 @@ -403,7 +401,7 @@ https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 iglo==1.2.6 # homeassistant.components.ihc -ihcsdk==2.1.1 +ihcsdk==2.2.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -413,7 +411,7 @@ influxdb==5.0.0 insteonlocal==0.53 # homeassistant.components.insteon_plm -insteonplm==0.7.5 +insteonplm==0.8.2 # homeassistant.components.verisure jsonpath==0.75 @@ -582,12 +580,15 @@ piglow==1.2.4 # homeassistant.components.pilight pilight==0.1.1 +# homeassistant.components.camera.proxy +pillow==5.0.0 + # homeassistant.components.dominos pizzapi==0.0.3 # homeassistant.components.media_player.plex # homeassistant.components.sensor.plex -plexapi==3.0.5 +plexapi==3.0.6 # homeassistant.components.sensor.mhz19 # homeassistant.components.sensor.serial_pm @@ -619,10 +620,10 @@ pushetta==1.0.15 pwmled==1.2.1 # homeassistant.components.august -py-august==0.3.0 +py-august==0.4.0 # homeassistant.components.canary -py-canary==0.4.0 +py-canary==0.4.1 # homeassistant.components.sensor.cpuspeed py-cpuinfo==3.3.0 @@ -644,7 +645,7 @@ pyHS100==0.3.0 pyRFXtrx==0.21.1 # homeassistant.components.sensor.tibber -pyTibber==0.2.1 +pyTibber==0.3.2 # homeassistant.components.switch.dlink pyW215==0.6.0 @@ -656,7 +657,7 @@ pyads==2.2.6 pyairvisual==1.0.0 # homeassistant.components.alarm_control_panel.alarmdotcom -pyalarmdotcom==0.3.0 +pyalarmdotcom==0.3.1 # homeassistant.components.arlo pyarlo==0.1.2 @@ -677,6 +678,9 @@ pybbox==0.0.5-alpha # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 +# homeassistant.components.media_player.channels +pychannels==1.0.0 + # homeassistant.components.media_player.cast pychromecast==2.0.0 @@ -711,11 +715,14 @@ pyebox==0.1.0 # homeassistant.components.climate.econet pyeconet==0.0.5 +# homeassistant.components.switch.edimax +pyedimax==0.1 + # homeassistant.components.eight_sleep pyeight==0.0.7 # homeassistant.components.media_player.emby -pyemby==1.4 +pyemby==1.5 # homeassistant.components.envisalink pyenvisalink==2.2 @@ -736,7 +743,7 @@ pyfttt==0.3 pyharmony==1.0.20 # homeassistant.components.binary_sensor.hikvision -pyhik==0.1.4 +pyhik==0.1.8 # homeassistant.components.hive pyhiveapi==0.2.11 @@ -782,7 +789,7 @@ pylgtv==0.1.7 pylitejet==0.1 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.17 +pyloopenergy==0.0.18 # homeassistant.components.lutron_caseta pylutron-caseta==0.3.0 @@ -958,6 +965,9 @@ python-roku==3.1.5 # homeassistant.components.sensor.sochain python-sochain-api==0.0.2 +# homeassistant.components.media_player.songpal +python-songpal==0.0.6 + # homeassistant.components.sensor.synologydsm python-synology==0.1.0 @@ -985,8 +995,9 @@ python_opendata_transport==0.0.3 # homeassistant.components.zwave python_openzwave==0.4.3 +# homeassistant.components.egardia # homeassistant.components.alarm_control_panel.egardia -pythonegardia==1.0.26 +pythonegardia==1.0.38 # homeassistant.components.sensor.whois pythonwhois==2.4.3 @@ -1001,7 +1012,7 @@ pytouchline==0.7 pytrackr==0.0.5 # homeassistant.components.tradfri -# pytradfri[async]==4.1.0 +pytradfri[async]==4.1.0 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -1019,7 +1030,7 @@ pyvizio==0.0.2 pyvlx==0.1.3 # homeassistant.components.notify.html5 -pywebpush==1.5.0 +pywebpush==1.6.0 # homeassistant.components.wemo pywemo==0.4.25 @@ -1094,6 +1105,9 @@ sendgrid==5.3.0 # homeassistant.components.sensor.sensehat sense-hat==2.2.0 +# homeassistant.components.sensor.sense +sense_energy==0.3.1 + # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 @@ -1204,12 +1218,12 @@ twilio==5.7.0 # homeassistant.components.sensor.uber uber_rides==0.6.0 +# homeassistant.components.upcloud +upcloud-api==0.4.2 + # homeassistant.components.sensor.ups upsmychoice==1.0.6 -# homeassistant.components.frontend -user-agents==1.1.0 - # homeassistant.components.camera.uvc uvcclient==0.10.1 @@ -1260,13 +1274,14 @@ xbee-helper==0.0.7 xboxapi==0.1.1 # homeassistant.components.knx -xknx==0.8.3 +xknx==0.8.4 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca # homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr +# homeassistant.components.sensor.zestimate xmltodict==0.11.0 # homeassistant.components.sensor.yahoo_finance diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9fa6e7f5cde..65e94172553 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,10 +19,10 @@ asynctest>=0.11.1 # homeassistant.components.homekit -HAP-python==1.1.5 +HAP-python==1.1.7 # homeassistant.components.notify.html5 -PyJWT==1.5.3 +PyJWT==1.6.0 # homeassistant.components.media_player.sonos SoCo==0.14 @@ -34,6 +34,9 @@ aioautomatic==0.6.5 # homeassistant.components.http aiohttp_cors==0.6.0 +# homeassistant.components.hue +aiohue==0.3.0 + # homeassistant.components.notify.apns apns2==0.3.0 @@ -75,7 +78,7 @@ hbmqtt==0.9.1 holidays==0.9.3 # homeassistant.components.frontend -home-assistant-frontend==20180227.0 +home-assistant-frontend==20180309.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -121,7 +124,7 @@ prometheus_client==0.1.0 pushbullet.py==0.11.0 # homeassistant.components.canary -py-canary==0.4.0 +py-canary==0.4.1 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -147,7 +150,7 @@ pythonwhois==2.4.3 pyunifi==2.13 # homeassistant.components.notify.html5 -pywebpush==1.5.0 +pywebpush==1.6.0 # homeassistant.components.python_script restrictedpython==4.0b2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 31e0635e411..a9a68d09491 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -5,6 +5,7 @@ import os import pkgutil import re import sys +import fnmatch COMMENT_REQUIREMENTS = ( 'RPi.GPIO', @@ -31,13 +32,13 @@ COMMENT_REQUIREMENTS = ( 'envirophat', 'i2csense', 'credstash', - 'pytradfri', 'bme680', ) TEST_REQUIREMENTS = ( 'aioautomatic', 'aiohttp_cors', + 'aiohue', 'apns2', 'caldav', 'coinmarketcap', @@ -93,9 +94,7 @@ TEST_REQUIREMENTS = ( IGNORE_PACKAGES = ( 'homeassistant.components.recorder.models', - 'homeassistant.components.homekit.accessories', - 'homeassistant.components.homekit.covers', - 'homeassistant.components.homekit.sensors' + 'homeassistant.components.homekit.*' ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') @@ -161,7 +160,10 @@ def gather_modules(): try: module = importlib.import_module(package) except ImportError: - if package not in IGNORE_PACKAGES: + for pattern in IGNORE_PACKAGES: + if fnmatch.fnmatch(package, pattern): + break + else: errors.append(package) continue diff --git a/script/lint b/script/lint index b16b92a45b4..bfce996788e 100755 --- a/script/lint +++ b/script/lint @@ -3,12 +3,14 @@ cd "$(dirname "$0")/.." -if [ "$1" = "--changed" ]; then +if [ "$1" = "--all" ]; then + tox -e lint +else export files="`git diff upstream/dev... --name-only | grep -e '\.py$'`" echo "=================================================" echo "FILES CHANGED (git diff upstream/dev... --name-only)" echo "=================================================" - if $files >/dev/null; then + if [ -z "$files" ] ; then echo "No python file changed" exit fi @@ -22,6 +24,4 @@ if [ "$1" = "--changed" ]; then echo "================" pylint $files echo -else - tox -e lint fi diff --git a/script/test b/script/test index 2f3f3557094..14fc357eb12 100755 --- a/script/test +++ b/script/test @@ -3,4 +3,4 @@ cd "$(dirname "$0")/.." -tox -e py34 +tox -e py35 diff --git a/script/translations_download b/script/translations_download new file mode 100755 index 00000000000..099e32c9d1b --- /dev/null +++ b/script/translations_download @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then + echo "Lokalise API token is required to download the latest set of" \ + "translations. Please create an account by using the following link:" \ + "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \ + "Place your token in a new file \".lokalise_token\" in the repo" \ + "root directory." + exit 1 +fi + +# Load token from file if not already in the environment +[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)" + +PROJECT_ID="130246255a974bd3b5e8a1.51616605" +LOCAL_DIR="$(pwd)/build/translations-download" +FILE_FORMAT=json + +mkdir -p ${LOCAL_DIR} + +docker run \ + -v ${LOCAL_DIR}:/opt/dest/locale \ + lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + --token ${LOKALISE_TOKEN} \ + export ${PROJECT_ID} \ + --export_empty skip \ + --type json \ + --unzip_to /opt/dest + +script/translations_download_split.py diff --git a/script/translations_download_split.py b/script/translations_download_split.py new file mode 100755 index 00000000000..03718cf7cab --- /dev/null +++ b/script/translations_download_split.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Merge all translation sources into a single JSON file.""" +import glob +import json +import os +import re +from typing import Union, List, Dict + +FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') + + +def load_json(filename: str) \ + -> Union[List, Dict]: + """Load JSON data from a file and return as dict or list. + + Defaults to returning empty dict if file is not found. + """ + with open(filename, encoding='utf-8') as fdesc: + return json.loads(fdesc.read()) + return {} + + +def save_json(filename: str, data: Union[List, Dict]): + """Save JSON data to a file. + + Returns True on success. + """ + data = json.dumps(data, sort_keys=True, indent=4) + with open(filename, 'w', encoding='utf-8') as fdesc: + fdesc.write(data) + return True + return False + + +def get_language(path): + """Get the language code for the given file path.""" + return os.path.splitext(os.path.basename(path))[0] + + +def get_component_path(lang, component): + """Get the component translation path.""" + if os.path.isdir(os.path.join("homeassistant", "components", component)): + return os.path.join( + "homeassistant", "components", component, ".translations", + "{}.json".format(lang)) + else: + return os.path.join( + "homeassistant", "components", ".translations", + "{}.{}.json".format(component, lang)) + + +def get_platform_path(lang, component, platform): + """Get the platform translation path.""" + if os.path.isdir(os.path.join( + "homeassistant", "components", component, platform)): + return os.path.join( + "homeassistant", "components", component, platform, + ".translations", "{}.json".format(lang)) + else: + return os.path.join( + "homeassistant", "components", component, ".translations", + "{}.{}.json".format(platform, lang)) + + +def get_component_translations(translations): + """Get the component level translations.""" + translations = translations.copy() + translations.pop('platform', None) + + return translations + + +def save_language_translations(lang, translations): + """Distribute the translations for this language.""" + components = translations.get('component', {}) + for component, component_translations in components.items(): + base_translations = get_component_translations(component_translations) + if base_translations: + path = get_component_path(lang, component) + os.makedirs(os.path.dirname(path), exist_ok=True) + save_json(path, base_translations) + + for platform, platform_translations in component_translations.get( + 'platform', {}).items(): + path = get_platform_path(lang, component, platform) + os.makedirs(os.path.dirname(path), exist_ok=True) + save_json(path, platform_translations) + + +def main(): + """Main section of the script.""" + if not os.path.isfile("requirements_all.txt"): + print("Run this from HA root dir") + return + + paths = glob.iglob("build/translations-download/*.json") + for path in paths: + lang = get_language(path) + translations = load_json(path) + save_language_translations(lang, translations) + + +if __name__ == '__main__': + main() diff --git a/script/translations_upload b/script/translations_upload new file mode 100755 index 00000000000..578cc8c0ccf --- /dev/null +++ b/script/translations_upload @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then + echo "Lokalise API token is required to download the latest set of" \ + "translations. Please create an account by using the following link:" \ + "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \ + "Place your token in a new file \".lokalise_token\" in the repo" \ + "root directory." + exit 1 +fi + +# Load token from file if not already in the environment +[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)" + +PROJECT_ID="130246255a974bd3b5e8a1.51616605" +LOCAL_FILE="$(pwd)/build/translations-upload.json" +LANG_ISO=en + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +if [ "${CURRENT_BRANCH-}" != "dev" ] && [ "${TRAVIS_BRANCH-}" != "dev" ] ; then + echo "Please only run the translations upload script from a clean checkout of dev." + exit 1 +fi + +script/translations_upload_merge.py + +docker run \ + -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ + lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + --token ${LOKALISE_TOKEN} \ + import ${PROJECT_ID} \ + --file /opt/src/${LOCAL_FILE} \ + --lang_iso ${LANG_ISO} \ + --replace 1 diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py new file mode 100755 index 00000000000..450a4c9ba0f --- /dev/null +++ b/script/translations_upload_merge.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Merge all translation sources into a single JSON file.""" +import glob +import itertools +import json +import os +import re +from typing import Union, List, Dict + +FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json') + + +def load_json(filename: str) \ + -> Union[List, Dict]: + """Load JSON data from a file and return as dict or list. + + Defaults to returning empty dict if file is not found. + """ + with open(filename, encoding='utf-8') as fdesc: + return json.loads(fdesc.read()) + return {} + + +def save_json(filename: str, data: Union[List, Dict]): + """Save JSON data to a file. + + Returns True on success. + """ + data = json.dumps(data, sort_keys=True, indent=4) + with open(filename, 'w', encoding='utf-8') as fdesc: + fdesc.write(data) + return True + return False + + +def find_strings_files(): + """Return the paths of the strings source files.""" + return itertools.chain( + glob.iglob("strings*.json"), + glob.iglob("*{}strings*.json".format(os.sep)), + ) + + +def get_component_platform(path): + """Get the component and platform name from the path.""" + directory, filename = os.path.split(path) + match = FILENAME_FORMAT.search(filename) + suffix = match.group('suffix') if match else None + if directory: + return directory, suffix + else: + return suffix, None + + +def get_translation_dict(translations, component, platform): + """Return the dict to hold component translations.""" + if not component: + return translations['component'] + + if component not in translations: + translations['component'][component] = {} + + if not platform: + return translations['component'][component] + + if 'platform' not in translations['component'][component]: + translations['component'][component]['platform'] = {} + + if platform not in translations['component'][component]['platform']: + translations['component'][component]['platform'][platform] = {} + + return translations['component'][component]['platform'][platform] + + +def main(): + """Main section of the script.""" + if not os.path.isfile("requirements_all.txt"): + print("Run this from HA root dir") + return + + root = os.getcwd() + os.chdir(os.path.join("homeassistant", "components")) + + translations = { + 'component': {} + } + + paths = find_strings_files() + for path in paths: + component, platform = get_component_platform(path) + parent = get_translation_dict(translations, component, platform) + strings = load_json(path) + parent.update(strings) + + os.chdir(root) + + os.makedirs("build", exist_ok=True) + + save_json( + os.path.join("build", "translations-upload.json"), translations) + + +if __name__ == '__main__': + main() diff --git a/script/travis_deploy b/script/travis_deploy new file mode 100755 index 00000000000..359f6a46077 --- /dev/null +++ b/script/travis_deploy @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# Safe bash settings +# -e Exit on command fail +# -u Exit on unset variable +# -o pipefail Exit if piped command has error code +set -eu -o pipefail + +cd "$(dirname "$0")/.." + +script/translations_upload diff --git a/setup.py b/setup.py index bca49d33647..024b2df3b38 100755 --- a/setup.py +++ b/setup.py @@ -1,7 +1,5 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" -import sys - from setuptools import setup, find_packages import homeassistant.const as hass_const @@ -27,7 +25,6 @@ PROJECT_CLASSIFIERS = [ 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Topic :: Home Automation' @@ -53,10 +50,8 @@ REQUIRES = [ 'jinja2>=2.10', 'voluptuous==0.11.1', 'typing>=3,<4', - 'aiohttp==2.3.10', # If updated, check if yarl also needs an update! - 'yarl==1.1.0', + 'aiohttp==3.0.6', 'async_timeout==2.0.0', - 'chardet==3.0.4', 'astral==1.5', 'certifi>=2017.4.17', 'attrs==17.4.0', @@ -64,9 +59,7 @@ REQUIRES = [ MIN_PY_VERSION = '.'.join(map( str, - hass_const.REQUIRED_PYTHON_VER_WIN - if sys.platform.startswith('win') - else hass_const.REQUIRED_PYTHON_VER)) + hass_const.REQUIRED_PYTHON_VER)) setup( name=PROJECT_PACKAGE_NAME, diff --git a/tests/common.py b/tests/common.py index 6fee7b1bec0..15ce80a9552 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,5 +1,6 @@ """Test the helper method for writing tests.""" import asyncio +from datetime import timedelta import functools as ft import os import sys @@ -298,7 +299,7 @@ def mock_registry(hass, mock_entries=None): """Mock the Entity Registry.""" registry = entity_registry.EntityRegistry(hass) registry.entities = mock_entries or {} - hass.data[entity_platform.DATA_REGISTRY] = registry + hass.data[entity_registry.DATA_REGISTRY] = registry return registry @@ -361,6 +362,32 @@ class MockPlatform(object): self.async_setup_platform = mock_coro_func() +class MockEntityPlatform(entity_platform.EntityPlatform): + """Mock class with some mock defaults.""" + + def __init__( + self, hass, + logger=None, + domain='test_domain', + platform_name='test_platform', + scan_interval=timedelta(seconds=15), + parallel_updates=0, + entity_namespace=None, + async_entities_added_callback=lambda: None + ): + """Initialize a mock entity platform.""" + super().__init__( + hass=hass, + logger=logger, + domain=domain, + platform_name=platform_name, + scan_interval=scan_interval, + parallel_updates=parallel_updates, + entity_namespace=entity_namespace, + async_entities_added_callback=async_entities_added_callback, + ) + + class MockToggleDevice(entity.ToggleEntity): """Provide a mock toggle device.""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py index 70cd5d83f41..a50a4d796aa 100644 --- a/tests/components/cloud/test_auth_api.py +++ b/tests/components/cloud/test_auth_api.py @@ -94,24 +94,6 @@ def test_register_fails(mock_cognito): auth_api.register(cloud, 'email@home-assistant.io', 'password') -def test_confirm_register(mock_cognito): - """Test confirming a registration of an account.""" - cloud = MagicMock() - auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') - assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 - result_code, result_user = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_user == 'email@home-assistant.io' - assert result_code == '123456' - - -def test_confirm_register_fails(mock_cognito): - """Test an error during confirmation of an account.""" - cloud = MagicMock() - mock_cognito.confirm_sign_up.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.confirm_register(cloud, '123456', 'email@home-assistant.io') - - def test_resend_email_confirm(mock_cognito): """Test starting forgot password flow.""" cloud = MagicMock() @@ -143,27 +125,6 @@ def test_forgot_password_fails(mock_cognito): auth_api.forgot_password(cloud, 'email@home-assistant.io') -def test_confirm_forgot_password(mock_cognito): - """Test confirming forgot password.""" - cloud = MagicMock() - auth_api.confirm_forgot_password( - cloud, '123456', 'email@home-assistant.io', 'new password') - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 - result_code, result_password = \ - mock_cognito.confirm_forgot_password.mock_calls[0][1] - assert result_code == '123456' - assert result_password == 'new password' - - -def test_confirm_forgot_password_fails(mock_cognito): - """Test failure when confirming forgot password.""" - cloud = MagicMock() - mock_cognito.confirm_forgot_password.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.confirm_forgot_password( - cloud, '123456', 'email@home-assistant.io', 'new password') - - def test_check_token_writes_new_token_on_refresh(mock_cognito): """Test check_token writes new token if refreshed.""" cloud = MagicMock() diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 69cd540e7d5..98ddebb5db3 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -231,53 +231,6 @@ def test_register_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_confirm_register_view(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 200 - assert len(mock_cognito.confirm_sign_up.mock_calls) == 1 - result_code, result_email = mock_cognito.confirm_sign_up.mock_calls[0][1] - assert result_email == 'hello@bla.com' - assert result_code == '123456' - - -@asyncio.coroutine -def test_confirm_register_view_bad_data(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'not_confirmation_code': '123456' - }) - assert req.status == 400 - assert len(mock_cognito.confirm_sign_up.mock_calls) == 0 - - -@asyncio.coroutine -def test_confirm_register_view_request_timeout(mock_cognito, cloud_client): - """Test timeout while logging out.""" - mock_cognito.confirm_sign_up.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_register_view_unknown_error(mock_cognito, cloud_client): - """Test unknown error while logging out.""" - mock_cognito.confirm_sign_up.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/confirm_register', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456' - }) - assert req.status == 502 - - @asyncio.coroutine def test_forgot_password_view(mock_cognito, cloud_client): """Test logging out.""" @@ -358,61 +311,3 @@ def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): 'email': 'hello@bla.com', }) assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_forgot_password_view(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 200 - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 1 - result_code, result_new_password = \ - mock_cognito.confirm_forgot_password.mock_calls[0][1] - assert result_code == '123456' - assert result_new_password == 'hello2' - - -@asyncio.coroutine -def test_confirm_forgot_password_view_bad_data(mock_cognito, cloud_client): - """Test logging out.""" - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'not_confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 400 - assert len(mock_cognito.confirm_forgot_password.mock_calls) == 0 - - -@asyncio.coroutine -def test_confirm_forgot_password_view_request_timeout(mock_cognito, - cloud_client): - """Test timeout while logging out.""" - mock_cognito.confirm_forgot_password.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 502 - - -@asyncio.coroutine -def test_confirm_forgot_password_view_unknown_error(mock_cognito, - cloud_client): - """Test unknown error while logging out.""" - mock_cognito.confirm_forgot_password.side_effect = auth_api.UnknownError - req = yield from cloud_client.post( - '/api/cloud/confirm_forgot_password', json={ - 'email': 'hello@bla.com', - 'confirmation_code': '123456', - 'new_password': 'hello2', - }) - assert req.status == 502 diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index d6a26ee37e0..f4ae81ad2f2 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -318,7 +318,6 @@ def test_handler_google_actions(hass): 'entity_config': { 'switch.test': { 'name': 'Config name', - 'type': 'light', 'aliases': 'Config alias' } } @@ -347,7 +346,7 @@ def test_handler_google_actions(hass): assert device['id'] == 'switch.test' assert device['name']['name'] == 'Config name' assert device['name']['nicknames'] == ['Config alias'] - assert device['type'] == 'action.devices.types.LIGHT' + assert device['type'] == 'action.devices.types.SWITCH' async def test_refresh_token_expired(hass): diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py new file mode 100644 index 00000000000..aa7a5ce5f0e --- /dev/null +++ b/tests/components/config/test_entity_registry.py @@ -0,0 +1,134 @@ +"""Test entity_registry API.""" +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.components.config import entity_registry +from tests.common import mock_registry, MockEntity, MockEntityPlatform + + +@pytest.fixture +def client(hass, test_client): + """Fixture that can interact with the config manager API.""" + hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) + hass.loop.run_until_complete(entity_registry.async_setup(hass)) + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + +async def test_get_entity(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.name': RegistryEntry( + entity_id='test_domain.name', + unique_id='1234', + platform='test_platform', + name='Hello World' + ), + 'test_domain.no_name': RegistryEntry( + entity_id='test_domain.no_name', + unique_id='6789', + platform='test_platform', + ), + }) + + resp = await client.get( + '/api/config/entity_registry/test_domain.name') + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.name', + 'name': 'Hello World' + } + + resp = await client.get( + '/api/config/entity_registry/test_domain.no_name') + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.no_name', + 'name': None + } + + +async def test_update_entity(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='before update' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'before update' + + resp = await client.post( + '/api/config/entity_registry/test_domain.world', json={ + 'name': 'after update' + }) + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.world', + 'name': 'after update' + } + + state = hass.states.get('test_domain.world') + assert state.name == 'after update' + + +async def test_update_entity_no_changes(hass, client): + """Test get entry.""" + mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='name of entity' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'name of entity' + + resp = await client.post( + '/api/config/entity_registry/test_domain.world', json={ + 'name': 'name of entity' + }) + assert resp.status == 200 + data = await resp.json() + assert data == { + 'entity_id': 'test_domain.world', + 'name': 'name of entity' + } + + state = hass.states.get('test_domain.world') + assert state.name == 'name of entity' + + +async def test_get_nonexisting_entity(client): + """Test get entry.""" + resp = await client.get( + '/api/config/entity_registry/test_domain.non_existing') + assert resp.status == 404 + + +async def test_update_nonexisting_entity(client): + """Test get entry.""" + resp = await client.post( + '/api/config/entity_registry/test_domain.non_existing', json={ + 'name': 'some name' + }) + assert resp.status == 404 diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 48ddf1d3692..27f28412561 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -19,6 +19,7 @@ from homeassistant.components.device_tracker.asuswrt import ( from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) +import pytest from tests.common import ( get_test_home_assistant, get_test_config_dir, assert_setup_component, mock_component) @@ -32,8 +33,7 @@ VALID_CONFIG_ROUTER_SSH = {DOMAIN: { CONF_PROTOCOL: 'ssh', CONF_MODE: 'router', CONF_PORT: '22' -} -} +}} WL_DATA = [ 'assoclist 01:02:03:04:06:08\r', @@ -119,6 +119,10 @@ def teardown_module(): os.remove(FAKEFILE) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): """Tests for the ASUSWRT device tracker platform.""" @@ -249,7 +253,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', + mock.call('fake_host', 'fake_user', quiet=False, ssh_key=FAKEFILE, port=22) ) @@ -275,7 +279,7 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual(ssh.login.call_count, 1) self.assertEqual( ssh.login.call_args, - mock.call('fake_host', 'fake_user', + mock.call('fake_host', 'fake_user', quiet=False, password='fake_pass', port=22) ) @@ -469,6 +473,10 @@ class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): self.assertEqual({}, scanner._get_leases(NEIGH_DEVICES.copy())) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestSshConnection(unittest.TestCase): """Testing SshConnection.""" @@ -509,6 +517,10 @@ class TestSshConnection(unittest.TestCase): self.assertIsNone(self.connection._ssh) +@pytest.mark.skip( + reason="These tests are performing actual failing network calls. They " + "need to be cleaned up before they are re-enabled. They're frequently " + "failing in Travis.") class TestTelnetConnection(unittest.TestCase): """Testing TelnetConnection.""" diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py index d90b5c0dd62..84ea84cdcad 100644 --- a/tests/components/device_tracker/test_automatic.py +++ b/tests/components/device_tracker/test_automatic.py @@ -114,8 +114,6 @@ def test_valid_credentials( result = hass.loop.run_until_complete( async_setup_scanner(hass, config, mock_see)) - hass.async_block_till_done() - assert result assert mock_create_session.called diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py index c66029b5fca..416b7be4a8a 100644 --- a/tests/components/device_tracker/test_ddwrt.py +++ b/tests/components/device_tracker/test_ddwrt.py @@ -7,6 +7,8 @@ import re import requests import requests_mock +import pytest + from homeassistant import config from homeassistant.setup import setup_component from homeassistant.components import device_tracker @@ -25,6 +27,7 @@ TEST_HOST = '127.0.0.1' _LOGGER = logging.getLogger(__name__) +@pytest.mark.skip class TestDdwrt(unittest.TestCase): """Tests for the Ddwrt device tracker platform.""" @@ -132,13 +135,15 @@ class TestDdwrt(unittest.TestCase): to the DD-WRT Lan Status request response fixture. This effectively checks the data parsing functions. """ + status_lan = load_fixture('Ddwrt_Status_Lan.txt') + with requests_mock.Mocker() as mock_request: mock_request.register_uri( 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, text=load_fixture('Ddwrt_Status_Wireless.txt')) mock_request.register_uri( 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) + text=status_lan) with assert_setup_component(1, DOMAIN): assert setup_component( @@ -153,12 +158,8 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) for device in devices: - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) - self.assertIn( - slugify(devices[device]['name']), - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) + self.assertIn(slugify(devices[device]['name']), status_lan) def test_device_name_no_data(self): """Test creating device info (MAC only) when no response.""" @@ -181,11 +182,10 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) + status_lan = load_fixture('Ddwrt_Status_Lan.txt') for device in devices: _LOGGER.error(devices[device]) - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) def test_device_name_no_dhcp(self): """Test creating device info (MAC) when missing dhcp response.""" @@ -210,11 +210,10 @@ class TestDdwrt(unittest.TestCase): path = self.hass.config.path(device_tracker.YAML_DEVICES) devices = config.load_yaml_config_file(path) + status_lan = load_fixture('Ddwrt_Status_Lan.txt') for device in devices: _LOGGER.error(devices[device]) - self.assertIn( - devices[device]['mac'], - load_fixture('Ddwrt_Status_Lan.txt')) + self.assertIn(devices[device]['mac'], status_lan) def test_update_no_data(self): """Test error handling of no response when active devices checked.""" diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 84cca1bb843..ebf568309ad 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -10,7 +10,7 @@ import os from homeassistant.components import zone from homeassistant.core import callback, State -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from homeassistant.helpers import discovery from homeassistant.loader import get_component from homeassistant.util.async import run_coroutine_threadsafe @@ -152,26 +152,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert setup_component(self.hass, device_tracker.DOMAIN, TEST_PLATFORM) - # pylint: disable=invalid-name - def test_adding_unknown_device_to_config(self): - """Test the adding of unknown devices to configuration file.""" - scanner = get_component('device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') - - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}) - - # wait for async calls (macvendor) to finish - self.hass.block_till_done() - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - assert config[0].dev_id == 'dev1' - assert config[0].track - def test_gravatar(self): """Test the Gravatar generation.""" dev_id = 'test' @@ -646,61 +626,6 @@ class TestComponentsDeviceTracker(unittest.TestCase): assert len(config) == 4 - def test_config_failure(self): - """Test that the device tracker see failures.""" - with assert_setup_component(0, device_tracker.DOMAIN): - setup_component(self.hass, device_tracker.DOMAIN, - {device_tracker.DOMAIN: { - device_tracker.CONF_CONSIDER_HOME: -1}}) - - def test_picture_and_icon_on_see_discovery(self): - """Test that picture and icon are set in initial see.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, {}, []) - tracker.see(dev_id=11, picture='pic_url', icon='mdi:icon') - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - assert config[0].icon == 'mdi:icon' - assert config[0].entity_picture == 'pic_url' - - def test_default_hide_if_away_is_used(self): - """Test that default track_new is used.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, - {device_tracker.CONF_AWAY_HIDE: True}, []) - tracker.see(dev_id=12) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertTrue(config[0].hidden) - - def test_backward_compatibility_for_track_new(self): - """Test backward compatibility for track new.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), False, - {device_tracker.CONF_TRACK_NEW: True}, []) - tracker.see(dev_id=13) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertFalse(config[0].track) - - def test_old_style_track_new_is_skipped(self): - """Test old style config is skipped.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), None, - {device_tracker.CONF_TRACK_NEW: False}, []) - tracker.see(dev_id=14) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - self.assertFalse(config[0].track) - @asyncio.coroutine def test_async_added_to_hass(hass): @@ -742,3 +667,66 @@ def test_bad_platform(hass): } with assert_setup_component(0, device_tracker.DOMAIN): assert (yield from device_tracker.async_setup(hass, config)) + + +async def test_adding_unknown_device_to_config(mock_device_tracker_conf, hass): + """Test the adding of unknown devices to configuration file.""" + scanner = get_component('device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + + await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}) + + await hass.async_block_till_done() + + assert len(mock_device_tracker_conf) == 1 + device = mock_device_tracker_conf[0] + assert device.dev_id == 'dev1' + assert device.track + + +async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, + hass): + """Test that picture and icon are set in initial see.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, {}, []) + await tracker.async_see(dev_id=11, picture='pic_url', icon='mdi:icon') + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].icon == 'mdi:icon' + assert mock_device_tracker_conf[0].entity_picture == 'pic_url' + + +async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass): + """Test that default track_new is used.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, + {device_tracker.CONF_AWAY_HIDE: True}, []) + await tracker.async_see(dev_id=12) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].away_hide + + +async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, + hass): + """Test backward compatibility for track new.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), False, + {device_tracker.CONF_TRACK_NEW: True}, []) + await tracker.async_see(dev_id=13) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].track is False + + +async def test_old_style_track_new_is_skipped(mock_device_tracker_conf, hass): + """Test old style config is skipped.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), None, + {device_tracker.CONF_TRACK_NEW: False}, []) + await tracker.async_see(dev_id=14) + await hass.async_block_till_done() + assert len(mock_device_tracker_conf) == 1 + assert mock_device_tracker_conf[0].track is False diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py index a739df804fd..74fc577bca8 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -1,8 +1,9 @@ """The tests the for Meraki device tracker.""" import asyncio import json -from unittest.mock import patch + import pytest + from homeassistant.components.device_tracker.meraki import ( CONF_VALIDATOR, CONF_SECRET) from homeassistant.setup import async_setup_component @@ -24,12 +25,11 @@ def meraki_client(loop, hass, test_client): } })) - with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(test_client(hass.http.app)) + yield loop.run_until_complete(test_client(hass.http.app)) @asyncio.coroutine -def test_invalid_or_missing_data(meraki_client): +def test_invalid_or_missing_data(mock_device_tracker_conf, meraki_client): """Test validator with invalid or missing data.""" req = yield from meraki_client.get(URL) text = yield from req.text() @@ -87,7 +87,7 @@ def test_invalid_or_missing_data(meraki_client): @asyncio.coroutine -def test_data_will_be_saved(hass, meraki_client): +def test_data_will_be_saved(mock_device_tracker_conf, hass, meraki_client): """Test with valid data.""" data = { "version": "2.0", @@ -130,6 +130,7 @@ def test_data_will_be_saved(hass, meraki_client): } req = yield from meraki_client.post(URL, data=json.dumps(data)) assert req.status == 200 + yield from hass.async_block_till_done() state_name = hass.states.get('{}.{}'.format('device_tracker', '0026abb8a9a4')).state assert 'home' == state_name diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index cba3c835763..91988a76212 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -420,11 +420,11 @@ def test_proper_put_state_request(hue_client): # pylint: disable=invalid-name -def perform_put_test_on_ceiling_lights(hass_hue, hue_client, - content_type='application/json'): +async def perform_put_test_on_ceiling_lights(hass_hue, hue_client, + content_type='application/json'): """Test the setting of a light.""" # Turn the office light off first - yield from hass_hue.services.async_call( + await hass_hue.services.async_call( light.DOMAIN, const.SERVICE_TURN_OFF, {const.ATTR_ENTITY_ID: 'light.ceiling_lights'}, blocking=True) @@ -433,14 +433,14 @@ def perform_put_test_on_ceiling_lights(hass_hue, hue_client, assert ceiling_lights.state == STATE_OFF # Go through the API to turn it on - office_result = yield from perform_put_light_state( + office_result = await perform_put_light_state( hass_hue, hue_client, 'light.ceiling_lights', True, 56, content_type) assert office_result.status == 200 assert 'application/json' in office_result.headers['content-type'] - office_result_json = yield from office_result.json() + office_result_json = await office_result.json() assert len(office_result_json) == 2 diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 022cf852b88..6c4dd713b32 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -36,7 +36,7 @@ DEMO_DEVICES = [{ 'traits': [ 'action.devices.traits.OnOff' ], - 'type': 'action.devices.types.LIGHT', # This is used for custom type + 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -230,20 +230,4 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.TemperatureSetting'], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False -}, { - 'id': 'sensor.outside_temperature', - 'name': { - 'name': 'Outside Temperature' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False -}, { - 'id': 'sensor.outside_humidity', - 'name': { - 'name': 'Outside Humidity' - }, - 'traits': ['action.devices.traits.TemperatureSetting'], - 'type': 'action.devices.types.THERMOSTAT', - 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 43c36d1ca2a..cb319b67bb2 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,9 +8,8 @@ import pytest from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, climate, async_setup, media_player, sensor) + fan, cover, light, switch, climate, async_setup, media_player) from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import IMPERIAL_SYSTEM from . import DEMO_DEVICES @@ -41,17 +40,6 @@ def assistant_client(loop, hass, test_client): 'aliases': ['top lights', 'ceiling lights'], 'name': 'Roof Lights', }, - 'switch.decorative_lights': { - 'type': 'light' - }, - 'sensor.outside_humidity': { - 'type': 'climate', - 'expose': True - }, - 'sensor.outside_temperature': { - 'type': 'climate', - 'expose': True - } } } })) @@ -105,13 +93,6 @@ def hass_fixture(loop, hass): }] })) - loop.run_until_complete( - setup.async_setup_component(hass, sensor.DOMAIN, { - 'sensor': [{ - 'platform': 'demo' - }] - })) - return hass @@ -196,7 +177,6 @@ def test_query_request(hass_fixture, assistant_client): assert devices['light.kitchen_lights']['color']['spectrumRGB'] == 16727919 assert devices['light.kitchen_lights']['color']['temperature'] == 4166 assert devices['media_player.lounge_room']['on'] is True - assert devices['media_player.lounge_room']['brightness'] == 100 @asyncio.coroutine @@ -213,8 +193,6 @@ def test_query_climate_request(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'}, - {'id': 'sensor.outside_humidity'} ] } }] @@ -227,47 +205,39 @@ def test_query_climate_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 20.0, - 'thermostatTemperatureAmbient': 25.0, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': 24, - 'thermostatTemperatureAmbient': 23, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': 21 - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': 21, - 'thermostatTemperatureAmbient': 22, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': 15.6 - }, - 'sensor.outside_humidity': { - 'on': True, - 'online': True, - 'thermostatHumidityAmbient': 54.0 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 20.0, + 'thermostatTemperatureAmbient': 25.0, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': 24, + 'thermostatTemperatureAmbient': 23, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': 21 + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': 21, + 'thermostatTemperatureAmbient': 22, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @asyncio.coroutine def test_query_climate_request_f(hass_fixture, assistant_client): """Test a query request.""" - hass_fixture.config.units = IMPERIAL_SYSTEM + # Mock demo devices as fahrenheit to see if we convert to celsius + for entity_id in ('climate.hvac', 'climate.heatpump', 'climate.ecobee'): + state = hass_fixture.states.get(entity_id) + attr = dict(state.attributes) + attr[const.ATTR_UNIT_OF_MEASUREMENT] = const.TEMP_FAHRENHEIT + hass_fixture.states.async_set(entity_id, state.state, attr) + reqid = '5711642932632160984' data = { 'requestId': @@ -279,7 +249,6 @@ def test_query_climate_request_f(hass_fixture, assistant_client): {'id': 'climate.hvac'}, {'id': 'climate.heatpump'}, {'id': 'climate.ecobee'}, - {'id': 'sensor.outside_temperature'} ] } }] @@ -292,35 +261,26 @@ def test_query_climate_request_f(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid devices = body['payload']['devices'] - assert devices == { - 'climate.heatpump': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.7, - 'thermostatTemperatureAmbient': -3.9, - 'thermostatMode': 'heat', - }, - 'climate.ecobee': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpointHigh': -4.4, - 'thermostatTemperatureAmbient': -5, - 'thermostatMode': 'heat', - 'thermostatTemperatureSetpointLow': -6.1, - }, - 'climate.hvac': { - 'on': True, - 'online': True, - 'thermostatTemperatureSetpoint': -6.1, - 'thermostatTemperatureAmbient': -5.6, - 'thermostatMode': 'cool', - 'thermostatHumidityAmbient': 54, - }, - 'sensor.outside_temperature': { - 'on': True, - 'online': True, - 'thermostatTemperatureAmbient': -9.1 - } + assert len(devices) == 3 + assert devices['climate.heatpump'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.7, + 'thermostatTemperatureAmbient': -3.9, + 'thermostatMode': 'heat', + } + assert devices['climate.ecobee'] == { + 'online': True, + 'thermostatTemperatureSetpointHigh': -4.4, + 'thermostatTemperatureAmbient': -5, + 'thermostatMode': 'heatcool', + 'thermostatTemperatureSetpointLow': -6.1, + } + assert devices['climate.hvac'] == { + 'online': True, + 'thermostatTemperatureSetpoint': -6.1, + 'thermostatTemperatureAmbient': -5.6, + 'thermostatMode': 'cool', + 'thermostatHumidityAmbient': 54, } @@ -359,19 +319,6 @@ def test_execute_request(hass_fixture, assistant_client): "brightness": 70 } }] - }, { - "devices": [{ - "id": "light.kitchen_lights", - }], - "execution": [{ - "command": "action.devices.commands.ColorAbsolute", - "params": { - "color": { - "spectrumRGB": 16711680, - "temperature": 2100 - } - } - }] }, { "devices": [{ "id": "light.kitchen_lights", @@ -415,13 +362,14 @@ def test_execute_request(hass_fixture, assistant_client): body = yield from result.json() assert body.get('requestId') == reqid commands = body['payload']['commands'] - assert len(commands) == 8 + assert len(commands) == 6 + + assert not any(result['status'] == 'ERROR' for result in commands) ceiling = hass_fixture.states.get('light.ceiling_lights') assert ceiling.state == 'off' kitchen = hass_fixture.states.get('light.kitchen_lights') - assert kitchen.attributes.get(light.ATTR_COLOR_TEMP) == 476 assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0) bed = hass_fixture.states.get('light.bed_light') diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index bb8f1b706e6..8d139fa8211 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,191 +1,246 @@ -"""The tests for the Google Actions component.""" -# pylint: disable=protected-access -import asyncio - -from homeassistant import const +"""Test Google Smart Home.""" +from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) +from homeassistant.setup import async_setup_component from homeassistant.components import climate -from homeassistant.components import google_assistant as ga -from homeassistant.util.unit_system import (IMPERIAL_SYSTEM, METRIC_SYSTEM) - -DETERMINE_SERVICE_TESTS = [{ # Test light brightness - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 95 - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'brightness': 242} - ) -}, { # Test light color temperature - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'temperature': 2300, - 'name': 'warm white' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'kelvin': 2300} - ) -}, { # Test light color blue - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 255, - 'name': 'blue' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [0, 0, 255]} - ) -}, { # Test light color yellow - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'spectrumRGB': 16776960, - 'name': 'yellow' - } - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'light.test', 'rgb_color': [255, 255, 0]} - ) -}, { # Test unhandled action/service - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_COLOR, - 'params': { - 'color': { - 'unhandled': 2300 - } - }, - 'expected': ( - None, - {'entity_id': 'light.test'} - ) -}, { # Test switch to light custom type - 'entity_id': 'switch.decorative_lights', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': ( - const.SERVICE_TURN_ON, - {'entity_id': 'switch.decorative_lights'} - ) -}, { # Test light on / off - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_TURN_OFF, {'entity_id': 'light.test'}) -}, { - 'entity_id': 'light.test', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_TURN_ON, {'entity_id': 'light.test'}) -}, { # Test Cover open close - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': True - }, - 'expected': (const.SERVICE_OPEN_COVER, {'entity_id': 'cover.bedroom'}), -}, { - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_ONOFF, - 'params': { - 'on': False - }, - 'expected': (const.SERVICE_CLOSE_COVER, {'entity_id': 'cover.bedroom'}), -}, { # Test cover position - 'entity_id': 'cover.bedroom', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 50 - }, - 'expected': ( - const.SERVICE_SET_COVER_POSITION, - {'entity_id': 'cover.bedroom', 'position': 50} - ), -}, { # Test media_player volume - 'entity_id': 'media_player.living_room', - 'command': ga.const.COMMAND_BRIGHTNESS, - 'params': { - 'brightness': 30 - }, - 'expected': ( - const.SERVICE_VOLUME_SET, - {'entity_id': 'media_player.living_room', 'volume_level': 0.3} - ), -}, { # Test climate temperature - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 24.5} - ), -}, { # Test climate temperature Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, - 'params': {'thermostatTemperatureSetpoint': 24.5}, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', 'temperature': 76.1} - ), -}, { # Test climate temperature range - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 24.5, 'target_temp_low': 20.5} - ), -}, { # Test climate temperature range Fahrenheit - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, - 'params': { - 'thermostatTemperatureSetpointHigh': 24.5, - 'thermostatTemperatureSetpointLow': 20.5, - }, - 'units': IMPERIAL_SYSTEM, - 'expected': ( - climate.SERVICE_SET_TEMPERATURE, - {'entity_id': 'climate.living_room', - 'target_temp_high': 76.1, 'target_temp_low': 68.9} - ), -}, { # Test climate operation mode - 'entity_id': 'climate.living_room', - 'command': ga.const.COMMAND_THERMOSTAT_SET_MODE, - 'params': {'thermostatMode': 'heat'}, - 'expected': ( - climate.SERVICE_SET_OPERATION_MODE, - {'entity_id': 'climate.living_room', 'operation_mode': 'heat'} - ), -}] +from homeassistant.components.google_assistant import ( + const, trait, helpers, smart_home as sh) +from homeassistant.components.light.demo import DemoLight -@asyncio.coroutine -def test_determine_service(): - """Test all branches of determine service.""" - for test in DETERMINE_SERVICE_TESTS: - result = ga.smart_home.determine_service( - test['entity_id'], - test['command'], - test['params'], - test.get('units', METRIC_SYSTEM)) - assert result == test['expected'] +BASIC_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', +) +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + + +async def test_sync_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + # This should not show up in the sync request + hass.states.async_set('sensor.no_match', 'something') + + # Excluded via config + hass.states.async_set('light.not_expose', 'on') + + config = helpers.Config( + should_expose=lambda state: state.entity_id != 'light.not_expose', + agent_user_id='test-agent', + entity_config={ + 'light.demo_light': { + const.CONF_ROOM_HINT: 'Living Room', + const.CONF_ALIASES: ['Hello', 'World'] + } + } + ) + + result = await sh.async_handle_message(hass, config, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'id': 'light.demo_light', + 'name': { + 'name': 'Demo Light', + 'nicknames': [ + 'Hello', + 'World', + ] + }, + 'traits': [ + trait.TRAIT_BRIGHTNESS, + trait.TRAIT_ONOFF, + trait.TRAIT_COLOR_SPECTRUM, + trait.TRAIT_COLOR_TEMP, + ], + 'type': sh.TYPE_LIGHT, + 'willReportState': False, + 'attributes': { + 'colorModel': 'rgb', + 'temperatureMinK': 6493, + 'temperatureMaxK': 2000, + }, + 'roomHint': 'Living Room' + }] + } + } + + +async def test_query_message(hass): + """Test a sync message.""" + light = DemoLight( + None, 'Demo Light', + state=False, + rgb=[237, 224, 33] + ) + light.hass = hass + light.entity_id = 'light.demo_light' + await light.async_update_ha_state() + + light2 = DemoLight( + None, 'Another Light', + state=True, + rgb=[237, 224, 33], + ct=400, + brightness=78, + ) + light2.hass = hass + light2.entity_id = 'light.another_light' + await light2.async_update_ha_state() + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'devices': { + 'light.non_existing': { + 'online': False, + }, + 'light.demo_light': { + 'on': False, + 'online': True, + }, + 'light.another_light': { + 'on': True, + 'online': True, + 'brightness': 30, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2500, + } + }, + } + } + } + + +async def test_execute(hass): + """Test an execute command.""" + await async_setup_component(hass, 'light', { + 'light': {'platform': 'demo'} + }) + await hass.services.async_call( + 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, + blocking=True) + + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['light.non_existing'], + "status": "ERROR", + "errorCode": "deviceOffline" + }, { + "ids": ['light.ceiling_lights'], + "status": "SUCCESS", + "states": { + "on": True, + "online": True, + 'brightness': 20, + 'color': { + 'spectrumRGB': 15589409, + 'temperature': 2631, + }, + } + }] + } + } + + +async def test_raising_error_trait(hass): + """Test raising an error while executing a trait command.""" + hass.states.async_set('climate.bla', climate.STATE_HEAT, { + climate.ATTR_MIN_TEMP: 15, + climate.ATTR_MAX_TEMP: 30, + ATTR_SUPPORTED_FEATURES: climate.SUPPORT_OPERATION_MODE, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + }) + result = await sh.async_handle_message(hass, BASIC_CONFIG, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] + }] + } + }] + }) + + assert result == { + "requestId": REQ_ID, + "payload": { + "commands": [{ + "ids": ['climate.bla'], + "status": "ERROR", + "errorCode": "valueOutOfRange" + }] + } + } diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py new file mode 100644 index 00000000000..90dd5d33581 --- /dev/null +++ b/tests/components/google_assistant/test_trait.py @@ -0,0 +1,580 @@ +"""Tests for the Google Assistant traits.""" +import pytest + +from homeassistant.const import ( + STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.components import ( + climate, + cover, + fan, + media_player, + light, + scene, + script, + switch, +) +from homeassistant.components.google_assistant import trait, helpers, const +from homeassistant.util import color + +from tests.common import async_mock_service + + +async def test_brightness_light(hass): + """Test brightness trait support for light domain.""" + assert trait.BrightnessTrait.supported(light.DOMAIN, + light.SUPPORT_BRIGHTNESS) + + trt = trait.BrightnessTrait(State('light.bla', light.STATE_ON, { + light.ATTR_BRIGHTNESS: 243 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 95 + } + + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_BRIGHTNESS_PCT: 50 + } + + +async def test_brightness_cover(hass): + """Test brightness trait support for cover domain.""" + assert trait.BrightnessTrait.supported(cover.DOMAIN, + cover.SUPPORT_SET_POSITION) + + trt = trait.BrightnessTrait(State('cover.bla', cover.STATE_OPEN, { + cover.ATTR_CURRENT_POSITION: 75 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 75 + } + + calls = async_mock_service( + hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 50 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + cover.ATTR_POSITION: 50 + } + + +async def test_brightness_media_player(hass): + """Test brightness trait support for media player domain.""" + assert trait.BrightnessTrait.supported(media_player.DOMAIN, + media_player.SUPPORT_VOLUME_SET) + + trt = trait.BrightnessTrait(State( + 'media_player.bla', media_player.STATE_PLAYING, { + media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 + })) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'brightness': 30 + } + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) + await trt.execute(hass, trait.COMMAND_BRIGHTNESS_ABSOLUTE, { + 'brightness': 60 + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + media_player.ATTR_MEDIA_VOLUME_LEVEL: .6 + } + + +async def test_onoff_group(hass): + """Test OnOff trait support for group domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('group.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('group.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'group.bla', + } + + +async def test_onoff_switch(hass): + """Test OnOff trait support for switch domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('switch.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('switch.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'switch.bla', + } + + +async def test_onoff_fan(hass): + """Test OnOff trait support for fan domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('fan.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('fan.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'fan.bla', + } + + +async def test_onoff_light(hass): + """Test OnOff trait support for light domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('light.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('light.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + } + + +async def test_onoff_cover(hass): + """Test OnOff trait support for cover domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('cover.bla', cover.STATE_OPEN)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('cover.bla', cover.STATE_CLOSED)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + off_calls = async_mock_service(hass, cover.DOMAIN, + cover.SERVICE_CLOSE_COVER) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'cover.bla', + } + + +async def test_onoff_media_player(hass): + """Test OnOff trait support for media_player domain.""" + assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) + + trt_on = trait.OnOffTrait(State('media_player.bla', STATE_ON)) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(State('media_player.bla', STATE_OFF)) + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) + await trt_on.execute(hass, trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'media_player.bla', + } + + +async def test_color_spectrum_light(hass): + """Test ColorSpectrum trait support for light domain.""" + assert not trait.ColorSpectrumTrait.supported(light.DOMAIN, 0) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_RGB_COLOR) + assert trait.ColorSpectrumTrait.supported(light.DOMAIN, + light.SUPPORT_XY_COLOR) + + trt = trait.ColorSpectrumTrait(State('light.bla', STATE_ON, { + light.ATTR_RGB_COLOR: [255, 10, 10] + })) + + assert trt.sync_attributes() == { + 'colorModel': 'rgb' + } + + assert trt.query_attributes() == { + 'color': { + 'spectrumRGB': 16714250 + } + } + + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 1052927 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_RGB_COLOR: [16, 16, 255] + } + + +async def test_color_temperature_light(hass): + """Test ColorTemperature trait support for light domain.""" + assert not trait.ColorTemperatureTrait.supported(light.DOMAIN, 0) + assert trait.ColorTemperatureTrait.supported(light.DOMAIN, + light.SUPPORT_COLOR_TEMP) + + trt = trait.ColorTemperatureTrait(State('light.bla', STATE_ON, { + light.ATTR_MIN_MIREDS: 200, + light.ATTR_COLOR_TEMP: 300, + light.ATTR_MAX_MIREDS: 500, + })) + + assert trt.sync_attributes() == { + 'temperatureMinK': 5000, + 'temperatureMaxK': 2000, + } + + assert trt.query_attributes() == { + 'color': { + 'temperature': 3333 + } + } + + assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 400 + } + }) + assert not trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'spectrumRGB': 16715792 + } + }) + + calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 5555 + } + }) + assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE + + await trt.execute(hass, trait.COMMAND_COLOR_ABSOLUTE, { + 'color': { + 'temperature': 2857 + } + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'light.bla', + light.ATTR_COLOR_TEMP: color.color_temperature_kelvin_to_mired(2857) + } + + +async def test_scene_scene(hass): + """Test Scene trait support for scene domain.""" + assert trait.SceneTrait.supported(scene.DOMAIN, 0) + + trt = trait.SceneTrait(State('scene.bla', scene.STATE)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'scene.bla', + } + + +async def test_scene_script(hass): + """Test Scene trait support for script domain.""" + assert trait.SceneTrait.supported(script.DOMAIN, 0) + + trt = trait.SceneTrait(State('script.bla', STATE_OFF)) + assert trt.sync_attributes() == {} + assert trt.query_attributes() == {} + assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) + + calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) + await trt.execute(hass, trait.COMMAND_ACTIVATE_SCENE, {}) + + # We don't wait till script execution is done. + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'script.bla', + } + + +async def test_temperature_setting_climate_range(hass): + """Test TemperatureSetting trait support for climate domain - range.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_CURRENT_TEMPERATURE: 70, + climate.ATTR_CURRENT_HUMIDITY: 25, + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + climate.STATE_HEAT, + climate.STATE_AUTO, + ], + climate.ATTR_TARGET_TEMP_HIGH: 75, + climate.ATTR_TARGET_TEMP_LOW: 65, + climate.ATTR_MIN_TEMP: 50, + climate.ATTR_MAX_TEMP: 80, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool,heat,heatcool', + 'thermostatTemperatureUnit': 'F', + } + assert trt.query_attributes() == { + 'thermostatMode': 'heatcool', + 'thermostatTemperatureAmbient': 21.1, + 'thermostatHumidityAmbient': 25, + 'thermostatTemperatureSetpointLow': 18.3, + 'thermostatTemperatureSetpointHigh': 23.9, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TARGET_TEMP_HIGH: 77, + climate.ATTR_TARGET_TEMP_LOW: 68, + } + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) + await trt.execute(hass, trait.COMMAND_THERMOSTAT_SET_MODE, { + 'thermostatMode': 'heatcool', + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_OPERATION_MODE: climate.STATE_AUTO, + } + + with pytest.raises(helpers.SmartHomeError) as err: + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE + + +async def test_temperature_setting_climate_setpoint(hass): + """Test TemperatureSetting trait support for climate domain - setpoint.""" + assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0) + assert trait.TemperatureSettingTrait.supported( + climate.DOMAIN, climate.SUPPORT_OPERATION_MODE) + + trt = trait.TemperatureSettingTrait(State( + 'climate.bla', climate.STATE_AUTO, { + climate.ATTR_OPERATION_MODE: climate.STATE_COOL, + climate.ATTR_OPERATION_LIST: [ + climate.STATE_OFF, + climate.STATE_COOL, + ], + climate.ATTR_MIN_TEMP: 10, + climate.ATTR_MAX_TEMP: 30, + climate.ATTR_TEMPERATURE: 18, + climate.ATTR_CURRENT_TEMPERATURE: 20, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, + })) + assert trt.sync_attributes() == { + 'availableThermostatModes': 'off,cool', + 'thermostatTemperatureUnit': 'C', + } + assert trt.query_attributes() == { + 'thermostatMode': 'cool', + 'thermostatTemperatureAmbient': 20, + 'thermostatTemperatureSetpoint': 18, + } + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {}) + assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {}) + + calls = async_mock_service( + hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) + + with pytest.raises(helpers.SmartHomeError): + await trt.execute( + hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': -100, + }) + + await trt.execute(hass, trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { + 'thermostatTemperatureSetpoint': 19, + }) + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + climate.ATTR_TEMPERATURE: 19 + } diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 1dd848d3058..31ad70e8aba 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -348,9 +348,15 @@ class TestComponentsGroup(unittest.TestCase): 'empty_group': {'name': 'Empty Group', 'entities': None}, }}) + group.Group.create_group( + self.hass, 'all tests', + ['test.one', 'test.two'], + user_defined=False) + assert sorted(self.hass.states.entity_ids()) == \ - ['group.empty_group', 'group.second_group', 'group.test_group'] - assert self.hass.bus.listeners['state_changed'] == 2 + ['group.all_tests', 'group.empty_group', 'group.second_group', + 'group.test_group'] + assert self.hass.bus.listeners['state_changed'] == 3 with patch('homeassistant.config.load_yaml_config_file', return_value={ 'group': { @@ -362,8 +368,9 @@ class TestComponentsGroup(unittest.TestCase): group.reload(self.hass) self.hass.block_till_done() - assert self.hass.states.entity_ids() == ['group.hello'] - assert self.hass.bus.listeners['state_changed'] == 1 + assert sorted(self.hass.states.entity_ids()) == \ + ['group.all_tests', 'group.hello'] + assert self.hass.bus.listeners['state_changed'] == 2 def test_changing_group_visibility(self): """Test that a group can be hidden and shown.""" diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py new file mode 100644 index 00000000000..a45aa82d981 --- /dev/null +++ b/tests/components/homekit/test_accessories.py @@ -0,0 +1,165 @@ +"""Test all functions related to the basic accessory implementation. + +This includes tests for all mock object types. +""" + +from unittest.mock import patch + +# pylint: disable=unused-import +from pyhap.loader import get_serv_loader, get_char_loader # noqa F401 + +from homeassistant.components.homekit.accessories import ( + set_accessory_info, add_preload_service, override_properties, + HomeAccessory, HomeBridge) +from homeassistant.components.homekit.const import ( + SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, + CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER) + +from tests.mock.homekit import ( + get_patch_paths, mock_preload_service, + MockTypeLoader, MockAccessory, MockService, MockChar) + +PATH_SERV = 'pyhap.loader.get_serv_loader' +PATH_CHAR = 'pyhap.loader.get_char_loader' +PATH_ACC, _ = get_patch_paths() + + +@patch(PATH_CHAR, return_value=MockTypeLoader('char')) +@patch(PATH_SERV, return_value=MockTypeLoader('service')) +def test_add_preload_service(mock_serv, mock_char): + """Test method add_preload_service. + + The methods 'get_serv_loader' and 'get_char_loader' are mocked. + """ + acc = MockAccessory('Accessory') + serv = add_preload_service(acc, 'TestService', + ['TestChar', 'TestChar2'], + ['TestOptChar', 'TestOptChar2']) + + assert serv.display_name == 'TestService' + assert len(serv.characteristics) == 2 + assert len(serv.opt_characteristics) == 2 + + acc.services = [] + serv = add_preload_service(acc, 'TestService') + + assert not serv.characteristics + assert not serv.opt_characteristics + + acc.services = [] + serv = add_preload_service(acc, 'TestService', + 'TestChar', 'TestOptChar') + + assert len(serv.characteristics) == 1 + assert len(serv.opt_characteristics) == 1 + + assert serv.characteristics[0].display_name == 'TestChar' + assert serv.opt_characteristics[0].display_name == 'TestOptChar' + + +def test_override_properties(): + """Test override of characteristic properties with MockChar.""" + char = MockChar('TestChar') + new_prop = {1: 'Test', 2: 'Demo'} + override_properties(char, new_prop) + + assert char.properties == new_prop + + +def test_set_accessory_info(): + """Test setting of basic accessory information with MockAccessory.""" + acc = MockAccessory('Accessory') + set_accessory_info(acc, 'model', 'manufacturer', '0000') + + assert len(acc.services) == 1 + serv = acc.services[0] + + assert serv.display_name == SERV_ACCESSORY_INFO + assert len(serv.characteristics) == 3 + chars = serv.characteristics + + assert chars[0].display_name == CHAR_MODEL + assert chars[0].value == 'model' + assert chars[1].display_name == CHAR_MANUFACTURER + assert chars[1].value == 'manufacturer' + assert chars[2].display_name == CHAR_SERIAL_NUMBER + assert chars[2].value == '0000' + + +@patch(PATH_ACC, side_effect=mock_preload_service) +def test_home_accessory(mock_pre_serv): + """Test initializing a HomeAccessory object.""" + acc = HomeAccessory('TestAccessory', 'test.accessory', 'WINDOW') + + assert acc.display_name == 'TestAccessory' + assert acc.category == 13 # Category.WINDOW + assert len(acc.services) == 1 + + serv = acc.services[0] + assert serv.display_name == SERV_ACCESSORY_INFO + char_model = serv.get_characteristic(CHAR_MODEL) + assert char_model.get_value() == 'test.accessory' + + +@patch(PATH_ACC, side_effect=mock_preload_service) +def test_home_bridge(mock_pre_serv): + """Test initializing a HomeBridge object.""" + bridge = HomeBridge('TestBridge', 'test.bridge', b'123-45-678') + + assert bridge.display_name == 'TestBridge' + assert bridge.pincode == b'123-45-678' + assert len(bridge.services) == 2 + + assert bridge.services[0].display_name == SERV_ACCESSORY_INFO + assert bridge.services[1].display_name == SERV_BRIDGING_STATE + + char_model = bridge.services[0].get_characteristic(CHAR_MODEL) + assert char_model.get_value() == 'test.bridge' + + +def test_mock_accessory(): + """Test attributes and functions of a MockAccessory.""" + acc = MockAccessory('TestAcc') + serv = MockService('TestServ') + acc.add_service(serv) + + assert acc.display_name == 'TestAcc' + assert len(acc.services) == 1 + + assert acc.get_service('TestServ') == serv + assert acc.get_service('NewServ').display_name == 'NewServ' + assert len(acc.services) == 2 + + +def test_mock_service(): + """Test attributes and functions of a MockService.""" + serv = MockService('TestServ') + char = MockChar('TestChar') + opt_char = MockChar('TestOptChar') + serv.add_characteristic(char) + serv.add_opt_characteristic(opt_char) + + assert serv.display_name == 'TestServ' + assert len(serv.characteristics) == 1 + assert len(serv.opt_characteristics) == 1 + + assert serv.get_characteristic('TestChar') == char + assert serv.get_characteristic('TestOptChar') == opt_char + assert serv.get_characteristic('NewChar').display_name == 'NewChar' + assert len(serv.characteristics) == 2 + + +def test_mock_char(): + """Test attributes and functions of a MockChar.""" + def callback_method(value): + """Provide a callback options for 'set_value' method.""" + assert value == 'With callback' + + char = MockChar('TestChar') + char.set_value('Value') + + assert char.display_name == 'TestChar' + assert char.get_value() == 'Value' + + char.setter_callback = callback_method + char.set_value('With callback') diff --git a/tests/components/homekit/test_covers.py b/tests/components/homekit/test_covers.py index b6e8334346a..fe0ede5d8fb 100644 --- a/tests/components/homekit/test_covers.py +++ b/tests/components/homekit/test_covers.py @@ -1,5 +1,6 @@ """Test different accessory types: Covers.""" import unittest +from unittest.mock import patch from homeassistant.core import callback from homeassistant.components.cover import ( @@ -10,6 +11,9 @@ from homeassistant.const import ( ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('covers') class TestHomekitSensors(unittest.TestCase): @@ -28,20 +32,21 @@ class TestHomekitSensors(unittest.TestCase): self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() def test_window_set_cover_position(self): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window' - acc = Window(self.hass, window_cover, 'Cover') - acc.run() + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Window(self.hass, window_cover, 'Cover') + acc.run() self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 - # self.assertEqual(acc.char_position_state.value, 0) + self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) @@ -49,8 +54,7 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - # Temporarily disabled due to bug in HAP-python==1.15 with py3.5 - # self.assertEqual(acc.char_position_state.value, 0) + self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e20e87871b8..6e49674a7b9 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -6,7 +6,7 @@ from homeassistant.components.homekit import ( TYPES, get_accessory, import_types) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, STATE_UNKNOWN) + TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) def test_import_types(): @@ -26,21 +26,32 @@ def test_component_not_supported(): assert True if get_accessory(None, state) is None else False -def test_sensor_temperatur_celsius(): - """Test temperature sensor with celsius as unit.""" +def test_sensor_temperature_celsius(): + """Test temperature sensor with Celsius as unit.""" mock_type = MagicMock() with patch.dict(TYPES, {'TemperatureSensor': mock_type}): - state = State('sensor.temperatur', '23', + state = State('sensor.temperature', '23', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) get_accessory(None, state) assert len(mock_type.mock_calls) == 1 +# pylint: disable=invalid-name +def test_sensor_temperature_fahrenheit(): + """Test temperature sensor with Fahrenheit as unit.""" + mock_type = MagicMock() + with patch.dict(TYPES, {'TemperatureSensor': mock_type}): + state = State('sensor.temperature', '74', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + get_accessory(None, state) + assert len(mock_type.mock_calls) == 1 + + def test_cover_set_position(): """Test cover with support for set_cover_position.""" mock_type = MagicMock() with patch.dict(TYPES, {'Window': mock_type}): - state = State('cover.setposition', 'open', + state = State('cover.set_position', 'open', {ATTR_SUPPORTED_FEATURES: 4}) get_accessory(None, state) assert len(mock_type.mock_calls) == 1 diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 06cb8096140..58c197e69ec 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,22 +1,25 @@ -"""Tests for the homekit component.""" +"""Tests for the HomeKit component.""" import unittest -from unittest.mock import patch +from unittest.mock import call, patch, ANY import voluptuous as vol +# pylint: disable=unused-import +from pyhap.accessory_driver import AccessoryDriver # noqa F401 + from homeassistant import setup from homeassistant.core import Event from homeassistant.components.homekit import ( - CONF_PIN_CODE, BRIDGE_NAME, Homekit, valid_pin) -from homeassistant.components.homekit.covers import Window -from homeassistant.components.homekit.sensors import TemperatureSensor + CONF_PIN_CODE, HOMEKIT_FILE, HomeKit, valid_pin) from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, PATH_HOMEKIT -HOMEKIT_PATH = 'homeassistant.components.homekit' +PATH_ACC, _ = get_patch_paths() +IP_ADDRESS = '127.0.0.1' CONFIG_MIN = {'homekit': {}} CONFIG = { @@ -27,98 +30,86 @@ CONFIG = { } -class TestHomekit(unittest.TestCase): - """Test the Multicover component.""" +class TestHomeKit(unittest.TestCase): + """Test setup of HomeKit component and HomeKit class.""" def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() - @patch(HOMEKIT_PATH + '.Homekit.start_driver') - @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') - @patch(HOMEKIT_PATH + '.Homekit.__init__') - def test_setup_min(self, mock_homekit, mock_setup_bridge, - mock_start_driver): - """Test async_setup with minimal config option.""" - mock_homekit.return_value = None - - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG_MIN)) - - mock_homekit.assert_called_once_with(self.hass, 51826) - mock_setup_bridge.assert_called_with(b'123-45-678') - mock_start_driver.assert_not_called() - - self.hass.start() - self.hass.block_till_done() - self.assertEqual(mock_start_driver.call_count, 1) - - @patch(HOMEKIT_PATH + '.Homekit.start_driver') - @patch(HOMEKIT_PATH + '.Homekit.setup_bridge') - @patch(HOMEKIT_PATH + '.Homekit.__init__') - def test_setup_parameters(self, mock_homekit, mock_setup_bridge, - mock_start_driver): - """Test async_setup with full config option.""" - mock_homekit.return_value = None - - self.assertTrue(setup.setup_component( - self.hass, 'homekit', CONFIG)) - - mock_homekit.assert_called_once_with(self.hass, 11111) - mock_setup_bridge.assert_called_with(b'987-65-432') - def test_validate_pincode(self): """Test async_setup with invalid config option.""" schema = vol.Schema(valid_pin) - for value in ('', '123-456-78', 'a23-45-678', '12345678'): + for value in ('', '123-456-78', 'a23-45-678', '12345678', 1234): with self.assertRaises(vol.MultipleInvalid): schema(value) for value in ('123-45-678', '234-56-789'): self.assertTrue(schema(value)) - @patch('pyhap.accessory_driver.AccessoryDriver.persist') - @patch('pyhap.accessory_driver.AccessoryDriver.stop') - @patch('pyhap.accessory_driver.AccessoryDriver.start') - @patch(HOMEKIT_PATH + '.import_types') - @patch(HOMEKIT_PATH + '.get_accessory') - def test_homekit_pyhap_interaction( - self, mock_get_accessory, mock_import_types, - mock_driver_start, mock_driver_stop, mock_file_persist): - """Test the interaction between the homekit class and pyhap.""" - acc1 = TemperatureSensor(self.hass, 'sensor.temp', 'Temperature') - acc2 = Window(self.hass, 'cover.hall_window', 'Cover') - mock_get_accessory.side_effect = [acc1, acc2] + @patch(PATH_HOMEKIT + '.HomeKit') + def test_setup_min(self, mock_homekit): + """Test async_setup with minimal config option.""" + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG_MIN)) - homekit = Homekit(self.hass, 51826) - homekit.setup_bridge(b'123-45-678') + self.assertEqual(mock_homekit.mock_calls, + [call(self.hass, 51826), + call().setup_bridge(b'123-45-678')]) + mock_homekit.reset_mock() - self.assertEqual(homekit.bridge.display_name, BRIDGE_NAME) + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + self.hass.block_till_done() + self.assertEqual(mock_homekit.mock_calls, + [call().start_driver(ANY)]) + + @patch(PATH_HOMEKIT + '.HomeKit') + def test_setup_parameters(self, mock_homekit): + """Test async_setup with full config option.""" + self.assertTrue(setup.setup_component( + self.hass, 'homekit', CONFIG)) + + self.assertEqual(mock_homekit.mock_calls, + [call(self.hass, 11111), + call().setup_bridge(b'987-65-432')]) + + @patch('pyhap.accessory_driver.AccessoryDriver') + def test_homekit_class(self, mock_acc_driver): + """Test interaction between the HomeKit class and pyhap.""" + with patch(PATH_HOMEKIT + '.accessories.HomeBridge') as mock_bridge: + homekit = HomeKit(self.hass, 51826) + homekit.setup_bridge(b'123-45-678') + + mock_bridge.reset_mock() self.hass.states.set('demo.demo1', 'on') self.hass.states.set('demo.demo2', 'off') - self.hass.start() - self.hass.block_till_done() + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc, \ + patch(PATH_HOMEKIT + '.import_types') as mock_import_types, \ + patch('homeassistant.util.get_local_ip') as mock_ip: + mock_get_acc.side_effect = ['TempSensor', 'Window'] + mock_ip.return_value = IP_ADDRESS + homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) - homekit.start_driver(Event(EVENT_HOMEASSISTANT_START)) + path = self.hass.config.path(HOMEKIT_FILE) - self.assertEqual(mock_get_accessory.call_count, 2) self.assertEqual(mock_import_types.call_count, 1) - self.assertEqual(mock_driver_start.call_count, 1) - - accessories = homekit.bridge.accessories - self.assertEqual(accessories[2], acc1) - self.assertEqual(accessories[3], acc2) - - mock_driver_stop.assert_not_called() + self.assertEqual(mock_get_acc.call_count, 2) + self.assertEqual(mock_bridge.mock_calls, + [call().add_accessory('TempSensor'), + call().add_accessory('Window')]) + self.assertEqual(mock_acc_driver.mock_calls, + [call(homekit.bridge, 51826, IP_ADDRESS, path), + call().start()]) + mock_acc_driver.reset_mock() self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP) self.hass.block_till_done() - self.assertEqual(mock_driver_stop.call_count, 1) + self.assertEqual(mock_acc_driver.mock_calls, [call().stop()]) diff --git a/tests/components/homekit/test_security_systems.py b/tests/components/homekit/test_security_systems.py new file mode 100644 index 00000000000..4753e86c084 --- /dev/null +++ b/tests/components/homekit/test_security_systems.py @@ -0,0 +1,92 @@ +"""Test different accessory types: Security Systems.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.homekit.security_systems import SecuritySystem +from homeassistant.const import ( + ATTR_SERVICE, EVENT_CALL_SERVICE, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED) + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('security_systems') + + +class TestHomekitSecuritySystems(unittest.TestCase): + """Test class for all accessory types regarding security systems.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_switch_set_state(self): + """Test if accessory and HA are updated accordingly.""" + acp = 'alarm_control_panel.testsecurity' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = SecuritySystem(self.hass, acp, 'SecuritySystem') + acc.run() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 3) + + self.hass.states.set(acp, STATE_ALARM_ARMED_AWAY) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 1) + self.assertEqual(acc.char_current_state.value, 1) + + self.hass.states.set(acp, STATE_ALARM_ARMED_HOME) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 0) + self.assertEqual(acc.char_current_state.value, 0) + + self.hass.states.set(acp, STATE_ALARM_ARMED_NIGHT) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 2) + self.assertEqual(acc.char_current_state.value, 2) + + self.hass.states.set(acp, STATE_ALARM_DISARMED) + self.hass.block_till_done() + self.assertEqual(acc.char_target_state.value, 3) + self.assertEqual(acc.char_current_state.value, 3) + + # Set from HomeKit + acc.char_target_state.set_value(0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') + self.assertEqual(acc.char_target_state.value, 0) + + acc.char_target_state.set_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') + self.assertEqual(acc.char_target_state.value, 1) + + acc.char_target_state.set_value(2) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') + self.assertEqual(acc.char_target_state.value, 2) + + acc.char_target_state.set_value(3) + self.hass.block_till_done() + self.assertEqual( + self.events[3].data[ATTR_SERVICE], 'alarm_disarm') + self.assertEqual(acc.char_target_state.value, 3) diff --git a/tests/components/homekit/test_sensors.py b/tests/components/homekit/test_sensors.py index b7d3de4e90b..4698c363503 100644 --- a/tests/components/homekit/test_sensors.py +++ b/tests/components/homekit/test_sensors.py @@ -1,11 +1,29 @@ """Test different accessory types: Sensors.""" import unittest +from unittest.mock import patch -from homeassistant.components.homekit.sensors import TemperatureSensor +from homeassistant.components.homekit.const import PROP_CELSIUS +from homeassistant.components.homekit.sensors import ( + TemperatureSensor, calc_temperature) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('sensors') + + +def test_calc_temperature(): + """Test if temperature in Celsius is calculated correctly.""" + assert calc_temperature(STATE_UNKNOWN) is None + assert calc_temperature('test') is None + + assert calc_temperature('20') == 20 + assert calc_temperature('20.12', TEMP_CELSIUS) == 20.12 + + assert calc_temperature('75.2', TEMP_FAHRENHEIT) == 24 + assert calc_temperature('-20.6', TEMP_FAHRENHEIT) == -29.22 class TestHomekitSensors(unittest.TestCase): @@ -14,24 +32,35 @@ class TestHomekitSensors(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + get_patch_paths('sensors') def tearDown(self): - """Stop down everthing that was started.""" + """Stop down everything that was started.""" self.hass.stop() - def test_temperature_celsius(self): + def test_temperature(self): """Test if accessory is updated after state change.""" temperature_sensor = 'sensor.temperature' - acc = TemperatureSensor(self.hass, temperature_sensor, 'Temperature') - acc.run() + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = TemperatureSensor(self.hass, temperature_sensor, + 'Temperature') + acc.run() self.assertEqual(acc.char_temp.value, 0.0) + self.assertEqual(acc.char_temp.properties, PROP_CELSIUS) self.hass.states.set(temperature_sensor, STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() - self.hass.states.set(temperature_sensor, '20') + self.hass.states.set(temperature_sensor, '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() self.assertEqual(acc.char_temp.value, 20) + + self.hass.states.set(temperature_sensor, '75.2', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 24) diff --git a/tests/components/homekit/test_switches.py b/tests/components/homekit/test_switches.py new file mode 100644 index 00000000000..d9f2d6c1d1a --- /dev/null +++ b/tests/components/homekit/test_switches.py @@ -0,0 +1,64 @@ +"""Test different accessory types: Switches.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.homekit.switches import Switch +from homeassistant.const import ATTR_SERVICE, EVENT_CALL_SERVICE + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('switches') + + +class TestHomekitSwitches(unittest.TestCase): + """Test class for all accessory types regarding switches.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_switch_set_state(self): + """Test if accessory and HA are updated accordingly.""" + switch = 'switch.testswitch' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Switch(self.hass, switch, 'Switch') + acc.run() + + self.assertEqual(acc.char_on.value, False) + + self.hass.states.set(switch, 'on') + self.hass.block_till_done() + self.assertEqual(acc.char_on.value, True) + + self.hass.states.set(switch, 'off') + self.hass.block_till_done() + self.assertEqual(acc.char_on.value, False) + + # Set from HomeKit + acc.char_on.set_value(True) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'turn_on') + self.assertEqual(acc.char_on.value, True) + + acc.char_on.set_value(False) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'turn_off') + self.assertEqual(acc.char_on.value, False) diff --git a/tests/components/homekit/test_thermostats.py b/tests/components/homekit/test_thermostats.py new file mode 100644 index 00000000000..fabffe881bb --- /dev/null +++ b/tests/components/homekit/test_thermostats.py @@ -0,0 +1,179 @@ +"""Test different accessory types: Thermostats.""" +import unittest +from unittest.mock import patch + +from homeassistant.core import callback +from homeassistant.components.climate import ( + ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, + ATTR_OPERATION_MODE, STATE_HEAT, STATE_AUTO) +from homeassistant.components.homekit.thermostats import Thermostat, STATE_OFF +from homeassistant.const import ( + ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + +from tests.common import get_test_home_assistant +from tests.mock.homekit import get_patch_paths, mock_preload_service + +PATH_ACC, PATH_FILE = get_patch_paths('thermostats') + + +class TestHomekitThermostats(unittest.TestCase): + """Test class for all accessory types regarding thermostats.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.events = [] + + @callback + def record_event(event): + """Track called event.""" + self.events.append(event) + + self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_default_thermostat(self): + """Test if accessory and HA are updated accordingly.""" + climate = 'climate.testclimate' + + with patch(PATH_ACC, side_effect=mock_preload_service): + with patch(PATH_FILE, side_effect=mock_preload_service): + acc = Thermostat(self.hass, climate, 'Climate', False) + acc.run() + + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 0) + self.assertEqual(acc.char_current_temp.value, 21.0) + self.assertEqual(acc.char_target_temp.value, 21.0) + self.assertEqual(acc.char_display_units.value, 0) + self.assertEqual(acc.char_cooling_thresh_temp, None) + self.assertEqual(acc.char_heating_thresh_temp, None) + + self.hass.states.set(climate, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 1) + self.assertEqual(acc.char_target_heat_cool.value, 1) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 23.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 1) + self.assertEqual(acc.char_current_temp.value, 23.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_target_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 0) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + # Set from HomeKit + acc.char_target_temp.set_value(19.0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_TEMPERATURE], 19.0) + self.assertEqual(acc.char_target_temp.value, 19.0) + + acc.char_target_heat_cool.set_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'set_operation_mode') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_OPERATION_MODE], + STATE_HEAT) + self.assertEqual(acc.char_target_heat_cool.value, 1) + + def test_auto_thermostat(self): + """Test if accessory and HA are updated accordingly.""" + climate = 'climate.testclimate' + + acc = Thermostat(self.hass, climate, 'Climate', True) + acc.run() + + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 22.0) + self.assertEqual(acc.char_current_heat_cool.value, 1) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 18.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 24.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_current_heat_cool.value, 2) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 24.0) + self.assertEqual(acc.char_display_units.value, 0) + + self.hass.states.set(climate, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) + self.assertEqual(acc.char_current_heat_cool.value, 0) + self.assertEqual(acc.char_target_heat_cool.value, 3) + self.assertEqual(acc.char_current_temp.value, 21.0) + self.assertEqual(acc.char_display_units.value, 0) + + # Set from HomeKit + acc.char_heating_thresh_temp.set_value(20.0) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[0].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_LOW], 20.0) + self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) + + acc.char_cooling_thresh_temp.set_value(25.0) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'set_temperature') + self.assertEqual( + self.events[1].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_HIGH], + 25.0) + self.assertEqual(acc.char_cooling_thresh_temp.value, 25.0) diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py index ef9817a2f1b..64f6c94c0da 100644 --- a/tests/components/http/__init__.py +++ b/tests/components/http/__init__.py @@ -1,5 +1,4 @@ """Tests for the HTTP component.""" -import asyncio from ipaddress import ip_address from aiohttp import web @@ -18,18 +17,16 @@ def mock_real_ip(app): nonlocal ip_to_mock ip_to_mock = value - @asyncio.coroutine @web.middleware - def mock_real_ip(request, handler): + async def mock_real_ip(request, handler): """Mock Real IP middleware.""" nonlocal ip_to_mock request[KEY_REAL_IP] = ip_address(ip_to_mock) - return (yield from handler(request)) + return (await handler(request)) - @asyncio.coroutine - def real_ip_startup(app): + async def real_ip_startup(app): """Startup of real ip.""" app.middlewares.insert(0, mock_real_ip) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index c2687c05a8f..604ee9c0c9b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -1,6 +1,5 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -import asyncio from ipaddress import ip_network from unittest.mock import patch @@ -30,8 +29,7 @@ TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1', UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1'] -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Return if request was authenticated.""" if not request[KEY_AUTHENTICATED]: raise HTTPUnauthorized @@ -47,84 +45,79 @@ def app(): return app -@asyncio.coroutine -def test_auth_middleware_loaded_by_default(hass): +async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_auth') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def test_access_without_password(app, test_client): +async def test_access_without_password(app, test_client): """Test access without password.""" setup_auth(app, [], None) - client = yield from test_client(app) + client = await test_client(app) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 200 -@asyncio.coroutine -def test_access_with_password_in_header(app, test_client): +async def test_access_with_password_in_header(app, test_client): """Test access with password in URL.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - req = yield from client.get( + req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 - req = yield from client.get( + req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'}) assert req.status == 401 -@asyncio.coroutine -def test_access_with_password_in_query(app, test_client): +async def test_access_with_password_in_query(app, test_client): """Test access without password.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - resp = yield from client.get('/', params={ + resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 - resp = yield from client.get('/', params={ + resp = await client.get('/', params={ 'api_password': 'wrong-password' }) assert resp.status == 401 -@asyncio.coroutine -def test_basic_auth_works(app, test_client): +async def test_basic_auth_works(app, test_client): """Test access with basic authentication.""" setup_auth(app, [], API_PASSWORD) - client = yield from test_client(app) + client = await test_client(app) - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('wrong_username', API_PASSWORD)) assert req.status == 401 - req = yield from client.get( + req = await client.get( '/', auth=BasicAuth('homeassistant', 'wrong password')) assert req.status == 401 - req = yield from client.get( + req = await client.get( '/', headers={ 'authorization': 'NotBasic abcdefg' @@ -132,8 +125,7 @@ def test_basic_auth_works(app, test_client): assert req.status == 401 -@asyncio.coroutine -def test_access_with_trusted_ip(test_client): +async def test_access_with_trusted_ip(test_client): """Test access with an untrusted ip address.""" app = web.Application() app.router.add_get('/', mock_handler) @@ -141,16 +133,16 @@ def test_access_with_trusted_ip(test_client): setup_auth(app, TRUSTED_NETWORKS, 'some-pass') set_mock_ip = mock_real_ip(app) - client = yield from test_client(app) + client = await test_client(app) for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401, \ "{} shouldn't be trusted".format(remote_addr) for remote_addr in TRUSTED_ADDRESSES: set_mock_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index bd6df4f4e73..2d7885d959f 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,6 +1,5 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -import asyncio from unittest.mock import patch, mock_open from aiohttp import web @@ -16,8 +15,7 @@ from . import mock_real_ip BANNED_IPS = ['200.201.202.203', '100.64.0.2'] -@asyncio.coroutine -def test_access_from_banned_ip(hass, test_client): +async def test_access_from_banned_ip(hass, test_client): """Test accessing to server from banned IP. Both trusted and not.""" app = web.Application() setup_bans(hass, app, 5) @@ -26,19 +24,18 @@ def test_access_from_banned_ip(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = yield from test_client(app) + client = await test_client(app) for remote_addr in BANNED_IPS: set_real_ip(remote_addr) - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 403 -@asyncio.coroutine -def test_ban_middleware_not_loaded_by_config(hass): +async def test_ban_middleware_not_loaded_by_config(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_bans') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': { http.CONF_IP_BAN_ENABLED: False, } @@ -47,25 +44,22 @@ def test_ban_middleware_not_loaded_by_config(hass): assert len(mock_setup.mock_calls) == 0 -@asyncio.coroutine -def test_ban_middleware_loaded_by_default(hass): +async def test_ban_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_bans') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def test_ip_bans_file_creation(hass, test_client): +async def test_ip_bans_file_creation(hass, test_client): """Testing if banned IP file created.""" app = web.Application() app['hass'] = hass - @asyncio.coroutine - def unauth_handler(request): + async def unauth_handler(request): """Return a mock web response.""" raise HTTPUnauthorized @@ -76,21 +70,21 @@ def test_ip_bans_file_creation(hass, test_client): with patch('homeassistant.components.http.ban.load_ip_bans_config', return_value=[IpBan(banned_ip) for banned_ip in BANNED_IPS]): - client = yield from test_client(app) + client = await test_client(app) m = mock_open() with patch('homeassistant.components.http.ban.open', m, create=True): - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) assert m.call_count == 0 - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 401 assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1 m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a') - resp = yield from client.get('/') + resp = await client.get('/') assert resp.status == 403 assert m.call_count == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 22b70e1c0c5..50464b36277 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -1,5 +1,4 @@ """Test cors for the HTTP component.""" -import asyncio from unittest.mock import patch from aiohttp import web @@ -20,22 +19,20 @@ from homeassistant.components.http.cors import setup_cors TRUSTED_ORIGIN = 'https://home-assistant.io' -@asyncio.coroutine -def test_cors_middleware_not_loaded_by_default(hass): +async def test_cors_middleware_not_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': {} }) assert len(mock_setup.mock_calls) == 0 -@asyncio.coroutine -def test_cors_middleware_loaded_from_config(hass): +async def test_cors_middleware_loaded_from_config(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: - yield from async_setup_component(hass, 'http', { + await async_setup_component(hass, 'http', { 'http': { 'cors_allowed_origins': ['http://home-assistant.io'] } @@ -44,8 +41,7 @@ def test_cors_middleware_loaded_from_config(hass): assert len(mock_setup.mock_calls) == 1 -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Return if request was authenticated.""" return web.Response(status=200) @@ -59,10 +55,9 @@ def client(loop, test_client): return loop.run_until_complete(test_client(app)) -@asyncio.coroutine -def test_cors_requests(client): +async def test_cors_requests(client): """Test cross origin requests.""" - req = yield from client.get('/', headers={ + req = await client.get('/', headers={ ORIGIN: TRUSTED_ORIGIN }) assert req.status == 200 @@ -70,7 +65,7 @@ def test_cors_requests(client): TRUSTED_ORIGIN # With password in URL - req = yield from client.get('/', params={ + req = await client.get('/', params={ 'api_password': 'some-pass' }, headers={ ORIGIN: TRUSTED_ORIGIN @@ -80,7 +75,7 @@ def test_cors_requests(client): TRUSTED_ORIGIN # With password in headers - req = yield from client.get('/', headers={ + req = await client.get('/', headers={ HTTP_HEADER_HA_AUTH: 'some-pass', ORIGIN: TRUSTED_ORIGIN }) @@ -89,10 +84,9 @@ def test_cors_requests(client): TRUSTED_ORIGIN -@asyncio.coroutine -def test_cors_preflight_allowed(client): +async def test_cors_preflight_allowed(client): """Test cross origin resource sharing preflight (OPTIONS) request.""" - req = yield from client.options('/', headers={ + req = await client.options('/', headers={ ORIGIN: TRUSTED_ORIGIN, ACCESS_CONTROL_REQUEST_METHOD: 'GET', ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access' diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index f00be4fc6f9..6cca1af8ccc 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -1,5 +1,4 @@ """Test data validator decorator.""" -import asyncio from unittest.mock import Mock from aiohttp import web @@ -9,8 +8,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -@asyncio.coroutine -def get_client(test_client, validator): +async def get_client(test_client, validator): """Generate a client that hits a view decorated with validator.""" app = web.Application() app['hass'] = Mock(is_running=True) @@ -20,58 +18,55 @@ def get_client(test_client, validator): name = 'test' requires_auth = False - @asyncio.coroutine @validator - def post(self, request, data): + async def post(self, request, data): """Test method.""" return b'' TestView().register(app.router) - client = yield from test_client(app) + client = await test_client(app) return client -@asyncio.coroutine -def test_validator(test_client): +async def test_validator(test_client): """Test the validator.""" - client = yield from get_client( + client = await get_client( test_client, RequestDataValidator(vol.Schema({ vol.Required('test'): str }))) - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 'bla' }) assert resp.status == 200 - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 100 }) assert resp.status == 400 - resp = yield from client.post('/') + resp = await client.post('/') assert resp.status == 400 -@asyncio.coroutine -def test_validator_allow_empty(test_client): +async def test_validator_allow_empty(test_client): """Test the validator with empty data.""" - client = yield from get_client( + client = await get_client( test_client, RequestDataValidator(vol.Schema({ # Although we allow empty, our schema should still be able # to validate an empty dict. vol.Optional('test'): str }), allow_empty=True)) - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 'bla' }) assert resp.status == 200 - resp = yield from client.post('/', json={ + resp = await client.post('/', json={ 'test': 100 }) assert resp.status == 400 - resp = yield from client.post('/') + resp = await client.post('/') assert resp.status == 200 diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index ab06b48043e..1dcf45f48c3 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,6 +1,4 @@ """The tests for the Home Assistant HTTP component.""" -import asyncio - from homeassistant.setup import async_setup_component import homeassistant.components.http as http @@ -12,16 +10,14 @@ class TestView(http.HomeAssistantView): name = 'test' url = '/hello' - @asyncio.coroutine - def get(self, request): + async def get(self, request): """Return a get request.""" return 'hello' -@asyncio.coroutine -def test_registering_view_while_running(hass, test_client, unused_port): +async def test_registering_view_while_running(hass, test_client, unused_port): """Test that we can register a view while the server is running.""" - yield from async_setup_component( + await async_setup_component( hass, http.DOMAIN, { http.DOMAIN: { http.CONF_SERVER_PORT: unused_port(), @@ -29,15 +25,14 @@ def test_registering_view_while_running(hass, test_client, unused_port): } ) - yield from hass.async_start() + await hass.async_start() # This raises a RuntimeError if app is frozen hass.http.register_view(TestView) -@asyncio.coroutine -def test_api_base_url_with_domain(hass): +async def test_api_base_url_with_domain(hass): """Test setting API URL.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'base_url': 'example.com' } @@ -46,10 +41,9 @@ def test_api_base_url_with_domain(hass): assert hass.config.api.base_url == 'http://example.com' -@asyncio.coroutine -def test_api_base_url_with_ip(hass): +async def test_api_base_url_with_ip(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'server_host': '1.1.1.1' } @@ -58,10 +52,9 @@ def test_api_base_url_with_ip(hass): assert hass.config.api.base_url == 'http://1.1.1.1:8123' -@asyncio.coroutine -def test_api_base_url_with_ip_port(hass): +async def test_api_base_url_with_ip_port(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { 'base_url': '1.1.1.1:8124' } @@ -70,10 +63,9 @@ def test_api_base_url_with_ip_port(hass): assert hass.config.api.base_url == 'http://1.1.1.1:8124' -@asyncio.coroutine -def test_api_no_base_url(hass): +async def test_api_no_base_url(hass): """Test setting api url.""" - result = yield from async_setup_component(hass, 'http', { + result = await async_setup_component(hass, 'http', { 'http': { } }) @@ -81,10 +73,9 @@ def test_api_no_base_url(hass): assert hass.config.api.base_url == 'http://127.0.0.1:8123' -@asyncio.coroutine -def test_not_log_password(hass, unused_port, test_client, caplog): +async def test_not_log_password(hass, unused_port, test_client, caplog): """Test access with password doesn't get logged.""" - result = yield from async_setup_component(hass, 'api', { + result = await async_setup_component(hass, 'api', { 'http': { http.CONF_SERVER_PORT: unused_port(), http.CONF_API_PASSWORD: 'some-pass' @@ -92,9 +83,9 @@ def test_not_log_password(hass, unused_port, test_client, caplog): }) assert result - client = yield from test_client(hass.http.app) + client = await test_client(hass.http.app) - resp = yield from client.get('/api/', params={ + resp = await client.get('/api/', params={ 'api_password': 'some-pass' }) diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py index 90201ab4c10..3e4f9023537 100644 --- a/tests/components/http/test_real_ip.py +++ b/tests/components/http/test_real_ip.py @@ -1,6 +1,4 @@ """Test real IP middleware.""" -import asyncio - from aiohttp import web from aiohttp.hdrs import X_FORWARDED_FOR @@ -8,41 +6,38 @@ from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_REAL_IP -@asyncio.coroutine -def mock_handler(request): +async def mock_handler(request): """Handler that returns the real IP as text.""" return web.Response(text=str(request[KEY_REAL_IP])) -@asyncio.coroutine -def test_ignore_x_forwarded_for(test_client): +async def test_ignore_x_forwarded_for(test_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, False) - mock_api_client = yield from test_client(app) + mock_api_client = await test_client(app) - resp = yield from mock_api_client.get('/', headers={ + resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' }) assert resp.status == 200 - text = yield from resp.text() + text = await resp.text() assert text != '255.255.255.255' -@asyncio.coroutine -def test_use_x_forwarded_for(test_client): +async def test_use_x_forwarded_for(test_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) setup_real_ip(app, True) - mock_api_client = yield from test_client(app) + mock_api_client = await test_client(app) - resp = yield from mock_api_client.get('/', headers={ + resp = await mock_api_client.get('/', headers={ X_FORWARDED_FOR: '255.255.255.255' }) assert resp.status == 200 - text = yield from resp.text() + text = await resp.text() assert text == '255.255.255.255' diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py new file mode 100644 index 00000000000..3c94fa2af3e --- /dev/null +++ b/tests/components/light/test_group.py @@ -0,0 +1,417 @@ +"""The tests for the Group Light platform.""" +from unittest.mock import MagicMock + +import asynctest + +from homeassistant.components import light +from homeassistant.components.light import group +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass): + """Test light group default state.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': [], 'name': 'Bedroom Group' + }}) + await hass.async_block_till_done() + + state = hass.states.get('light.bedroom_group') + assert state is not None + assert state.state == 'unavailable' + assert state.attributes['supported_features'] == 0 + assert state.attributes.get('brightness') is None + assert state.attributes.get('rgb_color') is None + assert state.attributes.get('xy_color') is None + assert state.attributes.get('color_temp') is None + assert state.attributes.get('white_value') is None + assert state.attributes.get('effect_list') is None + assert state.attributes.get('effect') is None + + +async def test_state_reporting(hass): + """Test the state reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on') + hass.states.async_set('light.test2', 'unavailable') + await hass.async_block_till_done() + assert hass.states.get('light.light_group').state == 'on' + + hass.states.async_set('light.test1', 'on') + hass.states.async_set('light.test2', 'off') + await hass.async_block_till_done() + assert hass.states.get('light.light_group').state == 'on' + + hass.states.async_set('light.test1', 'off') + hass.states.async_set('light.test2', 'off') + await hass.async_block_till_done() + assert hass.states.get('light.light_group').state == 'off' + + hass.states.async_set('light.test1', 'unavailable') + hass.states.async_set('light.test2', 'unavailable') + await hass.async_block_till_done() + assert hass.states.get('light.light_group').state == 'unavailable' + + +async def test_brightness(hass): + """Test brightness reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'brightness': 255, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['supported_features'] == 1 + assert state.attributes['brightness'] == 255 + + hass.states.async_set('light.test2', 'on', + {'brightness': 100, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['brightness'] == 177 + + hass.states.async_set('light.test1', 'off', + {'brightness': 255, 'supported_features': 1}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['supported_features'] == 1 + assert state.attributes['brightness'] == 100 + + +async def test_xy_color(hass): + """Test XY reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'xy_color': (1.0, 1.0), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['supported_features'] == 64 + assert state.attributes['xy_color'] == (1.0, 1.0) + + hass.states.async_set('light.test2', 'on', + {'xy_color': (0.5, 0.5), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['xy_color'] == (0.75, 0.75) + + hass.states.async_set('light.test1', 'off', + {'xy_color': (1.0, 1.0), 'supported_features': 64}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['xy_color'] == (0.5, 0.5) + + +async def test_rgb_color(hass): + """Test RGB reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'rgb_color': (255, 0, 0), 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.state == 'on' + assert state.attributes['supported_features'] == 16 + assert state.attributes['rgb_color'] == (255, 0, 0) + + hass.states.async_set('light.test2', 'on', + {'rgb_color': (255, 255, 255), + 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['rgb_color'] == (255, 127, 127) + + hass.states.async_set('light.test1', 'off', + {'rgb_color': (255, 0, 0), 'supported_features': 16}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['rgb_color'] == (255, 255, 255) + + +async def test_white_value(hass): + """Test white value reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'white_value': 255, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['white_value'] == 255 + + hass.states.async_set('light.test2', 'on', + {'white_value': 100, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['white_value'] == 177 + + hass.states.async_set('light.test1', 'off', + {'white_value': 255, 'supported_features': 128}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['white_value'] == 100 + + +async def test_color_temp(hass): + """Test color temp reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'color_temp': 2, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['color_temp'] == 2 + + hass.states.async_set('light.test2', 'on', + {'color_temp': 1000, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['color_temp'] == 501 + + hass.states.async_set('light.test1', 'off', + {'color_temp': 2, 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['color_temp'] == 1000 + + +async def test_min_max_mireds(hass): + """Test min/max mireds reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'min_mireds': 2, 'max_mireds': 5, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['min_mireds'] == 2 + assert state.attributes['max_mireds'] == 5 + + hass.states.async_set('light.test2', 'on', + {'min_mireds': 7, 'max_mireds': 1234567890, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['min_mireds'] == 2 + assert state.attributes['max_mireds'] == 1234567890 + + hass.states.async_set('light.test1', 'off', + {'min_mireds': 1, 'max_mireds': 2, + 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['min_mireds'] == 1 + assert state.attributes['max_mireds'] == 1234567890 + + +async def test_effect_list(hass): + """Test effect_list reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'effect_list': ['None', 'Random', 'Colorloop']}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop'} + + hass.states.async_set('light.test2', 'on', + {'effect_list': ['None', 'Random', 'Rainbow']}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop', 'Rainbow'} + + hass.states.async_set('light.test1', 'off', + {'effect_list': ['None', 'Colorloop', 'Seven']}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert set(state.attributes['effect_list']) == { + 'None', 'Random', 'Colorloop', 'Seven', 'Rainbow'} + + +async def test_effect(hass): + """Test effect reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2', + 'light.test3'] + }}) + + hass.states.async_set('light.test1', 'on', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test2', 'on', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test3', 'on', + {'effect': 'Random', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['effect'] == 'None' + + hass.states.async_set('light.test1', 'off', + {'effect': 'None', 'supported_features': 2}) + hass.states.async_set('light.test2', 'off', + {'effect': 'None', 'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['effect'] == 'Random' + + +async def test_supported_features(hass): + """Test supported features reporting.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'group', 'entities': ['light.test1', 'light.test2'] + }}) + + hass.states.async_set('light.test1', 'on', + {'supported_features': 0}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['supported_features'] == 0 + + hass.states.async_set('light.test2', 'on', + {'supported_features': 2}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['supported_features'] == 2 + + hass.states.async_set('light.test1', 'off', + {'supported_features': 41}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['supported_features'] == 43 + + hass.states.async_set('light.test2', 'off', + {'supported_features': 256}) + await hass.async_block_till_done() + state = hass.states.get('light.light_group') + assert state.attributes['supported_features'] == 41 + + +async def test_service_calls(hass): + """Test service calls.""" + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'demo'}, + {'platform': 'group', 'entities': ['light.bed_light', + 'light.ceiling_lights', + 'light.kitchen_lights']} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.light_group').state == 'on' + light.async_toggle(hass, 'light.light_group') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'off' + assert hass.states.get('light.ceiling_lights').state == 'off' + assert hass.states.get('light.kitchen_lights').state == 'off' + + light.async_turn_on(hass, 'light.light_group') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'on' + assert hass.states.get('light.ceiling_lights').state == 'on' + assert hass.states.get('light.kitchen_lights').state == 'on' + + light.async_turn_off(hass, 'light.light_group') + await hass.async_block_till_done() + + assert hass.states.get('light.bed_light').state == 'off' + assert hass.states.get('light.ceiling_lights').state == 'off' + assert hass.states.get('light.kitchen_lights').state == 'off' + + light.async_turn_on(hass, 'light.light_group', brightness=128, + effect='Random', rgb_color=(42, 255, 255)) + await hass.async_block_till_done() + + state = hass.states.get('light.bed_light') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + state = hass.states.get('light.ceiling_lights') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + state = hass.states.get('light.kitchen_lights') + assert state.state == 'on' + assert state.attributes['brightness'] == 128 + assert state.attributes['effect'] == 'Random' + assert state.attributes['rgb_color'] == (42, 255, 255) + + +async def test_invalid_service_calls(hass): + """Test invalid service call arguments get discarded.""" + add_devices = MagicMock() + await group.async_setup_platform(hass, { + 'entities': ['light.test1', 'light.test2'] + }, add_devices) + + assert add_devices.call_count == 1 + grouped_light = add_devices.call_args[0][0][0] + grouped_light.hass = hass + + with asynctest.patch.object(hass.services, 'async_call') as mock_call: + await grouped_light.async_turn_on(brightness=150, four_oh_four='404') + data = { + 'entity_id': ['light.test1', 'light.test2'], + 'brightness': 150 + } + mock_call.assert_called_once_with('light', 'turn_on', data, + blocking=True) + mock_call.reset_mock() + + await grouped_light.async_turn_off(transition=4, four_oh_four='404') + data = { + 'entity_id': ['light.test1', 'light.test2'], + 'transition': 4 + } + mock_call.assert_called_once_with('light', 'turn_off', data, + blocking=True) + mock_call.reset_mock() + + data = { + 'brightness': 150, + 'xy_color': (0.5, 0.42), + 'rgb_color': (80, 120, 50), + 'color_temp': 1234, + 'white_value': 1, + 'effect': 'Sunshine', + 'transition': 4, + 'flash': 'long' + } + await grouped_light.async_turn_on(**data) + data['entity_id'] = ['light.test1', 'light.test2'] + mock_call.assert_called_once_with('light', 'turn_on', data, + blocking=True) diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index ecfe3f36761..d35321b4479 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -7,10 +7,12 @@ from homeassistant.setup import setup_component import homeassistant.loader as loader from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, - SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE) + SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light +from homeassistant.helpers.intent import IntentHandleError -from tests.common import mock_service, get_test_home_assistant +from tests.common import ( + async_mock_service, mock_service, get_test_home_assistant) class TestLight(unittest.TestCase): @@ -302,3 +304,93 @@ class TestLight(unittest.TestCase): self.assertEqual( {light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100}, data) + + +async def test_intent_set_color(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: light.SUPPORT_RGB_COLOR + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + + +async def test_intent_set_color_tests_feature(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + try: + await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + } + }) + assert False, 'handling intent should have raised' + except IntentHandleError as err: + assert str(err) == 'Entity hello does not support changing colors' + + assert len(calls) == 0 + + +async def test_intent_set_color_and_brightness(hass): + """Test the set color intent.""" + hass.states.async_set('light.hello_2', 'off', { + ATTR_SUPPORTED_FEATURES: ( + light.SUPPORT_RGB_COLOR | light.SUPPORT_BRIGHTNESS) + }) + hass.states.async_set('switch.hello', 'off') + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) + hass.helpers.intent.async_register(light.SetIntentHandler()) + + result = await hass.helpers.intent.async_handle( + 'test', light.INTENT_SET, { + 'name': { + 'value': 'Hello', + }, + 'color': { + 'value': 'blue' + }, + 'brightness': { + 'value': '20' + } + }) + await hass.async_block_till_done() + + assert result.speech['plain']['speech'] == \ + 'Changed hello 2 to the color blue and 20% brightness' + + assert len(calls) == 1 + call = calls[0] + assert call.domain == light.DOMAIN + assert call.service == SERVICE_TURN_ON + assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' + assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) + assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index a798c5f3987..65ca2eb6a01 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -154,29 +154,21 @@ class TestDemoMediaPlayer(unittest.TestCase): {'media_player': {'platform': 'demo'}}) state = self.hass.states.get(entity_id) assert 1 == state.attributes.get('media_track') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 3 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 2 == state.attributes.get('media_track') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) assert setup_component( self.hass, mp.DOMAIN, @@ -184,22 +176,16 @@ class TestDemoMediaPlayer(unittest.TestCase): ent_id = 'media_player.lounge_room' state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_next_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 2 == state.attributes.get('media_episode') - assert 0 < (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) mp.media_previous_track(self.hass, ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 1 == state.attributes.get('media_episode') - assert 0 == (mp.SUPPORT_PREVIOUS_TRACK & - state.attributes.get('supported_features')) @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.' 'media_seek', autospec=True) diff --git a/tests/components/media_player/test_mediaroom.py b/tests/components/media_player/test_mediaroom.py deleted file mode 100644 index 7c7922b87be..00000000000 --- a/tests/components/media_player/test_mediaroom.py +++ /dev/null @@ -1,32 +0,0 @@ -"""The tests for the mediaroom media_player.""" - -import unittest - -from homeassistant.setup import setup_component -import homeassistant.components.media_player as media_player -from tests.common import ( - assert_setup_component, get_test_home_assistant) - - -class TestMediaroom(unittest.TestCase): - """Tests the Mediaroom Component.""" - - def setUp(self): - """Initialize values for this test case class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that we started.""" - self.hass.stop() - - def test_mediaroom_config(self): - """Test set up the platform with basic configuration.""" - config = { - media_player.DOMAIN: { - 'platform': 'mediaroom', - 'name': 'Living Room' - } - } - with assert_setup_component(1, media_player.DOMAIN) as result_config: - assert setup_component(self.hass, media_player.DOMAIN, config) - assert result_config[media_player.DOMAIN] diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py index f1a0f4a82fc..3470c79ad64 100644 --- a/tests/components/media_player/test_sonos.py +++ b/tests/components/media_player/test_sonos.py @@ -122,11 +122,13 @@ class SoCoMock(): return -def fake_add_device(devices, update_befor_add=False): - """Fake add device / update.""" - if update_befor_add: - for speaker in devices: - speaker.update() +def add_devices_factory(hass): + """Add devices factory.""" + def add_devices(devices, update_befor_add=False): + """Fake add device.""" + hass.data[sonos.DATA_SONOS].devices = devices + + return add_devices class TestSonosMediaPlayer(unittest.TestCase): @@ -156,7 +158,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_discovery(self, *args): """Test a single device using the autodiscovery provided by HASS.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) @@ -260,7 +262,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_ensure_setup_sonos_discovery(self, *args): """Test a single device using the autodiscovery provided by Sonos.""" - sonos.setup_platform(self.hass, {}, fake_add_device) + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass)) devices = self.hass.data[sonos.DATA_SONOS].devices self.assertEqual(len(devices), 1) self.assertEqual(devices[0].name, 'Kitchen') @@ -270,7 +272,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -284,7 +286,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(SoCoMock, 'set_sleep_timer') def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args): """Ensuring soco methods called for sonos_clear_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, mock.MagicMock(), { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -298,7 +300,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch('socket.create_connection', side_effect=socket.error()) def test_update_alarm(self, soco_mock, alarm_mock, *args): """Ensuring soco methods called for sonos_set_sleep_timer service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -328,7 +330,7 @@ class TestSonosMediaPlayer(unittest.TestCase): @mock.patch.object(soco.snapshot.Snapshot, 'snapshot') def test_sonos_snapshot(self, snapshotMock, *args): """Ensuring soco methods called for sonos_snapshot service.""" - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] @@ -346,7 +348,7 @@ class TestSonosMediaPlayer(unittest.TestCase): """Ensuring soco methods called for sonos_restor service.""" from soco.snapshot import Snapshot - sonos.setup_platform(self.hass, {}, fake_add_device, { + sonos.setup_platform(self.hass, {}, add_devices_factory(self.hass), { 'host': '192.0.2.1' }) device = self.hass.data[sonos.DATA_SONOS].devices[-1] diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 995f7e891f9..1dd29909ffd 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -21,8 +21,8 @@ def test_subscribing_config_topic(hass, mqtt_mock): assert call_args[2] == 0 -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_invalid_topic(mock_load_platform, hass, mqtt_mock): """Test sending to invalid topic.""" mock_load_platform.return_value = mock_coro() @@ -34,8 +34,8 @@ def test_invalid_topic(mock_load_platform, hass, mqtt_mock): assert not mock_load_platform.called -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): """Test sending in invalid JSON.""" mock_load_platform.return_value = mock_coro() @@ -48,8 +48,8 @@ def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): assert not mock_load_platform.called -@asyncio.coroutine @patch('homeassistant.components.mqtt.discovery.async_load_platform') +@asyncio.coroutine def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): """Test for a valid component.""" mock_load_platform.return_value = mock_coro() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 24308bc9a7e..1dd89a92f04 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -28,6 +28,7 @@ def async_mock_mqtt_client(hass, config=None): with mock.patch('paho.mqtt.client.Client') as mock_client: mock_client().connect.return_value = 0 mock_client().subscribe.return_value = (0, 0) + mock_client().unsubscribe.return_value = (0, 0) mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { mqtt.DOMAIN: config diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py index 7715ff168be..0bd0333a6fb 100644 --- a/tests/components/notify/test_apns.py +++ b/tests/components/notify/test_apns.py @@ -3,6 +3,7 @@ import io import unittest from unittest.mock import Mock, patch, mock_open +from apns2.errors import Unregistered import yaml import homeassistant.components.notify as notify @@ -358,8 +359,6 @@ class TestApns(unittest.TestCase): @patch('homeassistant.components.notify.apns._write_device') def test_disable_when_unregistered(self, mock_write, mock_client): """Test disabling a device when it is unregistered.""" - from apns2.errors import Unregistered - send = mock_client.return_value.send_notification send.side_effect = Unregistered() diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index d6c06f77d93..9ec71020ef1 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -1,5 +1,4 @@ """Test HTML5 notify platform.""" -import asyncio import json from unittest.mock import patch, MagicMock, mock_open from aiohttp.hdrs import AUTHORIZATION @@ -13,7 +12,7 @@ CONFIG_FILE = 'file.conf' SUBSCRIPTION_1 = { 'browser': 'chrome', 'subscription': { - 'endpoint': 'https://google.com', + 'endpoint': 'https://googleapis.com', 'keys': {'auth': 'auth', 'p256dh': 'p256dh'} }, } @@ -40,7 +39,7 @@ SUBSCRIPTION_3 = { SUBSCRIPTION_4 = { 'browser': 'chrome', 'subscription': { - 'endpoint': 'https://google.com', + 'endpoint': 'https://googleapis.com', 'expirationTime': None, 'keys': {'auth': 'auth', 'p256dh': 'p256dh'} }, @@ -50,21 +49,20 @@ REGISTER_URL = '/api/notify.html5' PUBLISH_URL = '/api/notify.html5/callback' -@asyncio.coroutine -def mock_client(hass, test_client, registrations=None): +async def mock_client(hass, test_client, registrations=None): """Create a test client for HTML5 views.""" if registrations is None: registrations = {} with patch('homeassistant.components.notify.html5._load_config', return_value=registrations): - yield from async_setup_component(hass, 'notify', { + await async_setup_component(hass, 'notify', { 'notify': { 'platform': 'html5' } }) - return (yield from test_client(hass.http.app)) + return await test_client(hass.http.app) class TestHtml5Notify(object): @@ -117,15 +115,48 @@ class TestHtml5Notify(object): assert payload['body'] == 'Hello' assert payload['icon'] == 'beer.png' + @patch('pywebpush.WebPusher') + def test_gcm_key_include(self, mock_wp): + """Test if the gcm_key is only included for GCM endpoints.""" + hass = MagicMock() -@asyncio.coroutine -def test_registering_new_device_view(hass, test_client): + data = { + 'chrome': SUBSCRIPTION_1, + 'firefox': SUBSCRIPTION_2 + } + + m = mock_open(read_data=json.dumps(data)) + with patch('homeassistant.util.json.open', m, create=True): + service = html5.get_service(hass, { + 'gcm_sender_id': '100', + 'gcm_api_key': 'Y6i0JdZ0mj9LOaSI' + }) + + assert service is not None + + service.send_message('Hello', target=['chrome', 'firefox']) + + assert len(mock_wp.mock_calls) == 6 + + # WebPusher constructor + assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription'] + assert mock_wp.mock_calls[3][1][0] == SUBSCRIPTION_2['subscription'] + + # Third mock_call checks the status_code of the response. + assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__' + assert mock_wp.mock_calls[5][0] == '().send().status_code.__eq__' + + # Get the keys passed to the WebPusher's send method + assert mock_wp.mock_calls[1][2]['gcm_key'] is not None + assert mock_wp.mock_calls[4][2]['gcm_key'] is None + + +async def test_registering_new_device_view(hass, test_client): """Test that the HTML view works.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) assert resp.status == 200 assert len(mock_save.mock_calls) == 1 @@ -134,14 +165,12 @@ def test_registering_new_device_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_expiration_view(hass, test_client): +async def test_registering_new_device_expiration_view(hass, test_client): """Test that the HTML view works.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 200 assert mock_save.mock_calls[0][1][1] == { @@ -149,32 +178,27 @@ def test_registering_new_device_expiration_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_fails_view(hass, test_client): +async def test_registering_new_device_fails_view(hass, test_client): """Test subs. are not altered when registering a new device fails.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 500 assert registrations == {} -@asyncio.coroutine -def test_registering_existing_device_view(hass, test_client): +async def test_registering_existing_device_view(hass, test_client): """Test subscription is updated when registering existing device.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 200 assert mock_save.mock_calls[0][1][1] == { @@ -185,18 +209,15 @@ def test_registering_existing_device_view(hass, test_client): } -@asyncio.coroutine -def test_registering_existing_device_fails_view(hass, test_client): +async def test_registering_existing_device_fails_view(hass, test_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_1)) + await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) mock_save.side_effect = HomeAssistantError - resp = yield from client.post(REGISTER_URL, - data=json.dumps(SUBSCRIPTION_4)) + resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) assert resp.status == 500 assert registrations == { @@ -204,42 +225,40 @@ def test_registering_existing_device_fails_view(hass, test_client): } -@asyncio.coroutine -def test_registering_new_device_validation(hass, test_client): +async def test_registering_new_device_validation(hass, test_client): """Test various errors when registering a new device.""" - client = yield from mock_client(hass, test_client) + client = await mock_client(hass, test_client) - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', 'subscription': 'sub info', })) assert resp.status == 400 - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'chrome', })) assert resp.status == 400 with patch('homeassistant.components.notify.html5.save_json', return_value=False): - resp = yield from client.post(REGISTER_URL, data=json.dumps({ + resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'chrome', 'subscription': 'sub info', })) assert resp.status == 400 -@asyncio.coroutine -def test_unregistering_device_view(hass, test_client): +async def test_unregistering_device_view(hass, test_client): """Test that the HTML unregister view works.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], })) @@ -250,14 +269,14 @@ def test_unregistering_device_view(hass, test_client): } -@asyncio.coroutine -def test_unregister_device_view_handle_unknown_subscription(hass, test_client): +async def test_unregister_device_view_handle_unknown_subscription(hass, + test_client): """Test that the HTML unregister view handles unknown subscriptions.""" registrations = {} - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_3['subscription'] })) @@ -266,18 +285,17 @@ def test_unregister_device_view_handle_unknown_subscription(hass, test_client): assert len(mock_save.mock_calls) == 0 -@asyncio.coroutine -def test_unregistering_device_view_handles_save_error(hass, test_client): +async def test_unregistering_device_view_handles_save_error(hass, test_client): """Test that the HTML unregister view handles save errors.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): - resp = yield from client.delete(REGISTER_URL, data=json.dumps({ + resp = await client.delete(REGISTER_URL, data=json.dumps({ 'subscription': SUBSCRIPTION_1['subscription'], })) @@ -288,11 +306,10 @@ def test_unregistering_device_view_handles_save_error(hass, test_client): } -@asyncio.coroutine -def test_callback_view_no_jwt(hass, test_client): +async def test_callback_view_no_jwt(hass, test_client): """Test that the notification callback view works without JWT.""" - client = yield from mock_client(hass, test_client) - resp = yield from client.post(PUBLISH_URL, data=json.dumps({ + client = await mock_client(hass, test_client) + resp = await client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' })) @@ -300,16 +317,15 @@ def test_callback_view_no_jwt(hass, test_client): assert resp.status == 401, resp.response -@asyncio.coroutine -def test_callback_view_with_jwt(hass, test_client): +async def test_callback_view_with_jwt(hass, test_client): """Test that the notification callback view works with JWT.""" registrations = { 'device': SUBSCRIPTION_1 } - client = yield from mock_client(hass, test_client, registrations) + client = await mock_client(hass, test_client, registrations) with patch('pywebpush.WebPusher') as mock_wp: - yield from hass.services.async_call('notify', 'notify', { + await hass.services.async_call('notify', 'notify', { 'message': 'Hello', 'target': ['device'], 'data': {'icon': 'beer.png'} @@ -331,10 +347,10 @@ def test_callback_view_with_jwt(hass, test_client): bearer_token = "Bearer {}".format(push_payload['data']['jwt']) - resp = yield from client.post(PUBLISH_URL, json={ + resp = await client.post(PUBLISH_URL, json={ 'type': 'push', }, headers={AUTHORIZATION: bearer_token}) assert resp.status == 200 - body = yield from resp.json() + body = await resp.json() assert body == {"event": "push", "status": "ok"} diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 2ae039b6712..91aa69b4484 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -2,6 +2,7 @@ import json from datetime import datetime, timedelta import unittest +from unittest.mock import patch from homeassistant.components import recorder from homeassistant.components.recorder.const import DATA_INSTANCE @@ -165,7 +166,7 @@ class TestRecorderPurge(unittest.TestCase): # run purge method - no service data, use defaults self.hass.services.call('recorder', 'purge') - self.hass.async_block_till_done() + self.hass.block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() @@ -177,7 +178,7 @@ class TestRecorderPurge(unittest.TestCase): # run purge method - correct service data self.hass.services.call('recorder', 'purge', service_data=service_data) - self.hass.async_block_till_done() + self.hass.block_till_done() # Small wait for recorder thread self.hass.data[DATA_INSTANCE].block_till_done() @@ -199,10 +200,12 @@ class TestRecorderPurge(unittest.TestCase): event.event_type for event in events.all())) # run purge method - correct service data, with repack - service_data['repack'] = True - self.assertFalse(self.hass.data[DATA_INSTANCE].did_vacuum) - self.hass.services.call('recorder', 'purge', - service_data=service_data) - self.hass.async_block_till_done() - self.hass.data[DATA_INSTANCE].block_till_done() - self.assertTrue(self.hass.data[DATA_INSTANCE].did_vacuum) + with patch('homeassistant.components.recorder.purge._LOGGER') \ + as mock_logger: + service_data['repack'] = True + self.hass.services.call('recorder', 'purge', + service_data=service_data) + self.hass.block_till_done() + self.hass.data[DATA_INSTANCE].block_till_done() + self.assertEqual(mock_logger.debug.mock_calls[4][1][0], + "Vacuuming SQLite to free space") diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py new file mode 100644 index 00000000000..dd1112d65f8 --- /dev/null +++ b/tests/components/sensor/test_filter.py @@ -0,0 +1,92 @@ +"""The test for the data filter sensor platform.""" +import unittest + +from homeassistant.components.sensor.filter import ( + LowPassFilter, OutlierFilter, ThrottleFilter) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, assert_setup_component + + +class TestFilterSensor(unittest.TestCase): + """Test the Data Filter sensor.""" + + def setup_method(self, method): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.values = [20, 19, 18, 21, 22, 0] + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_fail(self): + """Test if filter doesn't exist.""" + config = { + 'sensor': { + 'platform': 'filter', + 'entity_id': 'sensor.test_monitored', + 'filters': [{'filter': 'nonexisting'}] + } + } + with assert_setup_component(0): + assert setup_component(self.hass, 'sensor', config) + + def test_chain(self): + """Test if filter chaining works.""" + config = { + 'sensor': { + 'platform': 'filter', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'filters': [{ + 'filter': 'outlier', + 'radius': 4.0 + }, { + 'filter': 'lowpass', + 'window_size': 4, + 'time_constant': 10, + 'precision': 2 + }] + } + } + with assert_setup_component(1): + assert setup_component(self.hass, 'sensor', config) + + for value in self.values: + self.hass.states.set(config['sensor']['entity_id'], value) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual('20.25', state.state) + + def test_outlier(self): + """Test if outlier filter works.""" + filt = OutlierFilter(window_size=10, + precision=2, + entity=None, + radius=4.0) + for state in self.values: + filtered = filt.filter_state(state) + self.assertEqual(22, filtered) + + def test_lowpass(self): + """Test if lowpass filter works.""" + filt = LowPassFilter(window_size=10, + precision=2, + entity=None, + time_constant=10) + for state in self.values: + filtered = filt.filter_state(state) + self.assertEqual(18.05, filtered) + + def test_throttle(self): + """Test if lowpass filter works.""" + filt = ThrottleFilter(window_size=3, + precision=2, + entity=None) + filtered = [] + for state in self.values: + new_state = filt.filter_state(state) + if not filt.skip_processing: + filtered.append(new_state) + self.assertEqual([20, 21], filtered) diff --git a/tests/components/sensor/test_history_stats.py b/tests/components/sensor/test_history_stats.py index 7fdf732825b..1a2ec086e77 100644 --- a/tests/components/sensor/test_history_stats.py +++ b/tests/components/sensor/test_history_stats.py @@ -4,6 +4,7 @@ from datetime import timedelta import unittest from unittest.mock import patch +from homeassistant.const import STATE_UNKNOWN from homeassistant.setup import setup_component from homeassistant.components.sensor.history_stats import HistoryStatsSensor import homeassistant.core as ha @@ -43,8 +44,8 @@ class TestHistoryStatsSensor(unittest.TestCase): self.assertTrue(setup_component(self.hass, 'sensor', config)) - state = self.hass.states.get('sensor.test').as_dict() - self.assertEqual(state['state'], '0') + state = self.hass.states.get('sensor.test') + self.assertEqual(state.state, STATE_UNKNOWN) def test_period_parsing(self): """Test the conversion from templates to period.""" @@ -132,7 +133,7 @@ class TestHistoryStatsSensor(unittest.TestCase): sensor4.update() self.assertEqual(sensor1.state, 0.5) - self.assertEqual(sensor2.state, 0) + self.assertEqual(sensor2.state, None) self.assertEqual(sensor3.state, 2) self.assertEqual(sensor4.state, 50) diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py index eddab8caf4d..f2362867979 100644 --- a/tests/components/sensor/test_rest.py +++ b/tests/components/sensor/test_rest.py @@ -207,6 +207,18 @@ class TestRestSensor(unittest.TestCase): self.assertEqual('some_json_value', self.sensor.device_state_attributes['key']) + @patch('homeassistant.components.sensor.rest._LOGGER') + def test_update_with_json_attrs_no_data(self, mock_logger): + """Test attributes when no JSON result fetched.""" + self.rest.update = Mock('rest.RestData.update', + side_effect=self.update_side_effect(None)) + self.sensor = rest.RestSensor(self.hass, self.rest, self.name, + self.unit_of_measurement, None, ['key'], + self.force_update) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + @patch('homeassistant.components.sensor.rest._LOGGER') def test_update_with_json_attrs_not_dict(self, mock_logger): """Test attributes get extracted from a JSON result.""" diff --git a/tests/components/sensor/test_simulated.py b/tests/components/sensor/test_simulated.py new file mode 100644 index 00000000000..3bfccc629fd --- /dev/null +++ b/tests/components/sensor/test_simulated.py @@ -0,0 +1,50 @@ +"""The tests for the simulated sensor.""" +import unittest + +from homeassistant.components.sensor.simulated import ( + CONF_UNIT, CONF_AMP, CONF_MEAN, CONF_PERIOD, CONF_PHASE, CONF_FWHM, + CONF_SEED, DEFAULT_NAME, DEFAULT_AMP, DEFAULT_MEAN, + DEFAULT_PHASE, DEFAULT_FWHM, DEFAULT_SEED) +from homeassistant.const import CONF_FRIENDLY_NAME +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + + +class TestSimulatedSensor(unittest.TestCase): + """Test the simulated sensor.""" + + def setup_method(self, method): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + + def teardown_method(self, method): + """Stop everything that was started.""" + self.hass.stop() + + def test_default_config(self): + """Test default config.""" + config = { + 'sensor': { + 'platform': 'simulated'} + } + self.assertTrue( + setup_component(self.hass, 'sensor', config)) + self.hass.block_till_done() + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.simulated') + assert state.attributes.get( + CONF_FRIENDLY_NAME) == DEFAULT_NAME + assert state.attributes.get( + CONF_AMP) == DEFAULT_AMP + assert state.attributes.get( + CONF_UNIT) is None + assert state.attributes.get( + CONF_MEAN) == DEFAULT_MEAN + assert state.attributes.get( + CONF_PERIOD) == 60.0 + assert state.attributes.get( + CONF_PHASE) == DEFAULT_PHASE + assert state.attributes.get( + CONF_FWHM) == DEFAULT_FWHM + assert state.attributes.get( + CONF_SEED) == DEFAULT_SEED diff --git a/tests/components/sensor/test_sql.py b/tests/components/sensor/test_sql.py index ebf2d749e67..5e639b9f338 100644 --- a/tests/components/sensor/test_sql.py +++ b/tests/components/sensor/test_sql.py @@ -1,7 +1,11 @@ """The test for the sql sensor platform.""" import unittest +import pytest +import voluptuous as vol +from homeassistant.components.sensor.sql import validate_sql_select from homeassistant.setup import setup_component +from homeassistant.const import STATE_UNKNOWN from tests.common import get_test_home_assistant @@ -35,3 +39,25 @@ class TestSQLSensor(unittest.TestCase): state = self.hass.states.get('sensor.count_tables') self.assertEqual(state.state, '0') + + def test_invalid_query(self): + """Test the SQL sensor for invalid queries.""" + with pytest.raises(vol.Invalid): + validate_sql_select("DROP TABLE *") + + config = { + 'sensor': { + 'platform': 'sql', + 'db_url': 'sqlite://', + 'queries': [{ + 'name': 'count_tables', + 'query': 'SELECT * value FROM sqlite_master;', + 'column': 'value', + }] + } + } + + assert setup_component(self.hass, 'sensor', config) + + state = self.hass.states.get('sensor.count_tables') + self.assertEqual(state.state, STATE_UNKNOWN) diff --git a/tests/components/sensor/test_teksavvy.py b/tests/components/sensor/test_teksavvy.py new file mode 100644 index 00000000000..2c493d04050 --- /dev/null +++ b/tests/components/sensor/test_teksavvy.py @@ -0,0 +1,185 @@ +"""Tests for the TekSavvy sensor platform.""" +import asyncio +from homeassistant.bootstrap import async_setup_component +from homeassistant.components.sensor.teksavvy import TekSavvyData +from homeassistant.helpers.aiohttp_client import async_get_clientsession + + +@asyncio.coroutine +def test_capped_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'teksavvy', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 400, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'onpeak_download', + 'onpeak_upload', + 'onpeak_total', + 'offpeak_download', + 'offpeak_upload', + 'offpeak_total', + 'onpeak_remaining']} + + result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\ + '#UsageSummaryRecords","value":[{'\ + '"StartDate":"2018-01-01T00:00:00",'\ + '"EndDate":"2018-01-31T00:00:00",'\ + '"OID":"999999","IsCurrent":true,'\ + '"OnPeakDownload":226.75,'\ + '"OnPeakUpload":8.82,'\ + '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\ + '}]}' + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.teksavvy_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '400' + + state = hass.states.get('sensor.teksavvy_off_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '36.24' + + state = hass.states.get('sensor.teksavvy_off_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '1.58' + + state = hass.states.get('sensor.teksavvy_off_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '37.82' + + state = hass.states.get('sensor.teksavvy_on_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_on_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '8.82' + + state = hass.states.get('sensor.teksavvy_on_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '235.57' + + state = hass.states.get('sensor.teksavvy_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '56.69' + + state = hass.states.get('sensor.teksavvy_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '173.25' + + +@asyncio.coroutine +def test_unlimited_setup(hass, aioclient_mock): + """Test the default setup.""" + config = {'platform': 'teksavvy', + 'api_key': 'NOTAKEY', + 'total_bandwidth': 0, + 'monitored_variables': [ + 'usage', + 'usage_gb', + 'limit', + 'onpeak_download', + 'onpeak_upload', + 'onpeak_total', + 'offpeak_download', + 'offpeak_upload', + 'offpeak_total', + 'onpeak_remaining']} + + result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\ + '#UsageSummaryRecords","value":[{'\ + '"StartDate":"2018-01-01T00:00:00",'\ + '"EndDate":"2018-01-31T00:00:00",'\ + '"OID":"999999","IsCurrent":true,'\ + '"OnPeakDownload":226.75,'\ + '"OnPeakUpload":8.82,'\ + '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\ + '}]}' + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text=result) + + yield from async_setup_component(hass, 'sensor', {'sensor': config}) + + state = hass.states.get('sensor.teksavvy_data_limit') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + state = hass.states.get('sensor.teksavvy_off_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '36.24' + + state = hass.states.get('sensor.teksavvy_off_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '1.58' + + state = hass.states.get('sensor.teksavvy_off_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '37.82' + + state = hass.states.get('sensor.teksavvy_on_peak_download') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_on_peak_upload') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '8.82' + + state = hass.states.get('sensor.teksavvy_on_peak_total') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '235.57' + + state = hass.states.get('sensor.teksavvy_usage') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == '226.75' + + state = hass.states.get('sensor.teksavvy_usage_ratio') + assert state.attributes.get('unit_of_measurement') == '%' + assert state.state == '0' + + state = hass.states.get('sensor.teksavvy_remaining') + assert state.attributes.get('unit_of_measurement') == 'GB' + assert state.state == 'inf' + + +@asyncio.coroutine +def test_bad_return_code(hass, aioclient_mock): + """Test handling a return code that isn't HTTP OK.""" + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + status=404) + + tsd = TekSavvyData(hass.loop, async_get_clientsession(hass), + 'notakey', 400) + + result = yield from tsd.async_update() + assert result is False + + +@asyncio.coroutine +def test_bad_json_decode(hass, aioclient_mock): + """Test decoding invalid json result.""" + aioclient_mock.get("https://api.teksavvy.com/" + "web/Usage/UsageSummaryRecords?" + "$filter=IsCurrent%20eq%20true", + text='this is not json') + + tsd = TekSavvyData(hass.loop, async_get_clientsession(hass), + 'notakey', 400) + + result = yield from tsd.async_update() + assert result is False diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py index 1b8215660bd..064d0b1825b 100644 --- a/tests/components/switch/test_rest.py +++ b/tests/components/switch/test_rest.py @@ -82,6 +82,7 @@ class TestRestSwitchSetup: 'platform': 'rest', 'name': 'foo', 'resource': 'http://localhost', + 'headers': {'Content-type': 'application/json'}, 'body_on': 'custom on text', 'body_off': 'custom off text', } @@ -99,12 +100,13 @@ class TestRestSwitch: self.name = 'foo' self.method = 'post' self.resource = 'http://localhost/' + self.headers = {'Content-type': 'application/json'} self.auth = None self.body_on = Template('on', self.hass) self.body_off = Template('off', self.hass) self.switch = rest.RestSwitch( - self.name, self.resource, self.method, self.auth, self.body_on, - self.body_off, None, 10) + self.name, self.resource, self.method, self.headers, self.auth, + self.body_on, self.body_off, None, 10) self.switch.hass = self.hass def teardown_method(self): diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 8d629321853..466dc57017a 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -237,7 +237,7 @@ def test_http_api(hass, test_client): calls = async_mock_service(hass, 'homeassistant', 'turn_on') resp = yield from client.post('/api/conversation/process', json={ - 'text': 'Turn kitchen on' + 'text': 'Turn the kitchen on' }) assert resp.status == 200 @@ -267,3 +267,56 @@ def test_http_api_wrong_data(hass, test_client): resp = yield from client.post('/api/conversation/process', json={ }) assert resp.status == 400 + + +def test_create_matcher(): + """Test the create matcher method.""" + # Basic sentence + pattern = conversation._create_matcher('Hello world') + assert pattern.match('Hello world') is not None + + # Match a part + pattern = conversation._create_matcher('Hello {name}') + match = pattern.match('hello world') + assert match is not None + assert match.groupdict()['name'] == 'world' + no_match = pattern.match('Hello world, how are you?') + assert no_match is None + + # Optional and matching part + pattern = conversation._create_matcher('Turn on [the] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn off kitchen lights') + assert match is None + + # Two different optional parts, 1 matching part + pattern = conversation._create_matcher('Turn on [the] [a] {name}') + match = pattern.match('turn on the kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on kitchen lights') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn on a kitchen light') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Strip plural + pattern = conversation._create_matcher('Turn {name}[s] on') + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen light' + + # Optional 2 words + pattern = conversation._create_matcher('Turn [the great] {name} on') + match = pattern.match('turn the great kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' + match = pattern.match('turn kitchen lights on') + assert match is not None + assert match.groupdict()['name'] == 'kitchen lights' diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py index 93aac65ecb5..258e3d96297 100644 --- a/tests/components/test_demo.py +++ b/tests/components/test_demo.py @@ -10,6 +10,12 @@ from homeassistant.components import demo, device_tracker from homeassistant.remote import JSONEncoder +@pytest.fixture(autouse=True) +def mock_history(hass): + """Mock history component loaded.""" + hass.config.components.add('history') + + @pytest.fixture def minimize_demo_platforms(hass): """Cleanup demo component for tests.""" diff --git a/tests/components/test_hue.py b/tests/components/test_hue.py index 30129ec7998..fa61cb2b69e 100644 --- a/tests/components/test_hue.py +++ b/tests/components/test_hue.py @@ -4,13 +4,17 @@ import logging import unittest from unittest.mock import call, MagicMock, patch +import aiohue +import pytest +import voluptuous as vol + from homeassistant.components import configurator, hue from homeassistant.const import CONF_FILENAME, CONF_HOST from homeassistant.setup import setup_component, async_setup_component from tests.common import ( assert_setup_component, get_test_home_assistant, get_test_config_dir, - MockDependency + MockDependency, MockConfigEntry, mock_coro ) _LOGGER = logging.getLogger(__name__) @@ -212,7 +216,8 @@ class TestHueBridge(unittest.TestCase): mock_bridge = mock_phue.Bridge mock_bridge.side_effect = ConnectionRefusedError() - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertTrue(bridge.config_request_id is None) @@ -228,7 +233,8 @@ class TestHueBridge(unittest.TestCase): mock_phue.PhueRegistrationException = Exception mock_bridge.side_effect = mock_phue.PhueRegistrationException(1, 2) - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -250,7 +256,8 @@ class TestHueBridge(unittest.TestCase): None, ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -291,7 +298,8 @@ class TestHueBridge(unittest.TestCase): ConnectionRefusedError(), ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -332,7 +340,8 @@ class TestHueBridge(unittest.TestCase): mock_phue.PhueRegistrationException(1, 2), ] - bridge = hue.HueBridge('localhost', self.hass, hue.PHUE_CONFIG_FILE) + bridge = hue.HueBridge( + 'localhost', self.hass, hue.PHUE_CONFIG_FILE, None) bridge.setup() self.assertFalse(bridge.configured) self.assertFalse(bridge.config_request_id is None) @@ -364,7 +373,7 @@ class TestHueBridge(unittest.TestCase): """Test the hue_activate_scene service.""" with patch('homeassistant.helpers.discovery.load_platform'): bridge = hue.HueBridge('localhost', self.hass, - hue.PHUE_CONFIG_FILE) + hue.PHUE_CONFIG_FILE, None) bridge.setup() # No args @@ -393,15 +402,187 @@ class TestHueBridge(unittest.TestCase): bridge.bridge.run_scene.assert_called_once_with('group', 'scene') -@asyncio.coroutine -def test_setup_no_host(hass, requests_mock): +async def test_setup_no_host(hass, requests_mock): """No host specified in any way.""" requests_mock.get(hue.API_NUPNP, json=[]) with MockDependency('phue') as mock_phue: - result = yield from async_setup_component( + result = await async_setup_component( hass, hue.DOMAIN, {hue.DOMAIN: {}}) assert result mock_phue.Bridge.assert_not_called() assert hass.data[hue.DOMAIN] == {} + + +async def test_flow_works(hass, aioclient_mock): + """Test config flow .""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + + flow = hue.HueFlowHandler() + flow.hass = hass + await flow.async_step_init() + + with patch('aiohue.Bridge') as mock_bridge: + def mock_constructor(host, websession): + mock_bridge.host = host + return mock_bridge + + mock_bridge.side_effect = mock_constructor + mock_bridge.username = 'username-abc' + mock_bridge.config.name = 'Mock Bridge' + mock_bridge.config.bridgeid = 'bridge-id-1234' + mock_bridge.create_user.return_value = mock_coro() + mock_bridge.initialize.return_value = mock_coro() + + result = await flow.async_step_link(user_input={}) + + assert mock_bridge.host == '1.2.3.4' + assert len(mock_bridge.create_user.mock_calls) == 1 + assert len(mock_bridge.initialize.mock_calls) == 1 + + assert result['type'] == 'create_entry' + assert result['title'] == 'Mock Bridge' + assert result['data'] == { + 'host': '1.2.3.4', + 'bridge_id': 'bridge-id-1234', + 'username': 'username-abc' + } + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock): + """Test config flow discovers only already configured bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_one_bridge_discovered(hass, aioclient_mock): + """Test config flow discovers one bridge.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_flow_two_bridges_discovered(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'init' + + with pytest.raises(vol.Invalid): + assert result['data_schema']({'host': '0.0.0.0'}) + + result['data_schema']({'host': '1.2.3.4'}) + result['data_schema']({'host': '5.6.7.8'}) + + +async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock): + """Test config flow discovers two bridges.""" + aioclient_mock.get(hue.API_NUPNP, json=[ + {'internalipaddress': '1.2.3.4', 'id': 'bla'}, + {'internalipaddress': '5.6.7.8', 'id': 'beer'} + ]) + MockConfigEntry(domain='hue', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = hue.HueFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.host == '5.6.7.8' + + +async def test_flow_timeout_discovery(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.discovery.discover_nupnp', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_init() + + assert result['type'] == 'abort' + + +async def test_flow_link_timeout(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=asyncio.TimeoutError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } + + +async def test_flow_link_button_not_pressed(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.LinkButtonNotPressed): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } + + +async def test_flow_link_unknown_host(hass): + """Test config flow .""" + flow = hue.HueFlowHandler() + flow.hass = hass + + with patch('aiohue.Bridge.create_user', + side_effect=aiohue.RequestError): + result = await flow.async_step_link({}) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == { + 'base': 'Failed to register, please try again.' + } diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 4d12e436c02..c909a8488be 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -14,6 +14,9 @@ from tests.common import get_test_home_assistant @mock.patch('influxdb.InfluxDBClient') +@mock.patch( + 'homeassistant.components.influxdb.InfluxThread.batch_timeout', + mock.Mock(return_value=0)) class TestInfluxDB(unittest.TestCase): """Test the InfluxDB component.""" @@ -210,6 +213,37 @@ class TestInfluxDB(unittest.TestCase): ) mock_client.return_value.write_points.reset_mock() + def test_event_listener_inf(self, mock_client): + """Test the event listener for missing units.""" + self._setup() + + attrs = {'bignumstring': "9" * 999} + state = mock.MagicMock( + state=8, domain='fake', entity_id='fake.entity-id', + object_id='entity', attributes=attrs) + event = mock.MagicMock(data={'new_state': state}, time_fired=12345) + body = [{ + 'measurement': 'fake.entity-id', + 'tags': { + 'domain': 'fake', + 'entity_id': 'entity', + }, + 'time': 12345, + 'fields': { + 'value': 8, + }, + }] + self.handler_method(event) + self.hass.data[influxdb.DOMAIN].block_till_done() + self.assertEqual( + mock_client.return_value.write_points.call_count, 1 + ) + self.assertEqual( + mock_client.return_value.write_points.call_args, + mock.call(body) + ) + mock_client.return_value.write_points.reset_mock() + def test_event_listener_states(self, mock_client): """Test the event listener against ignored states.""" self._setup() diff --git a/tests/components/test_init.py b/tests/components/test_init.py index fff3b74c831..eca4763b4b3 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -212,7 +212,7 @@ def test_turn_on_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test light' + assert response.speech['plain']['speech'] == 'Turned test light on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -234,7 +234,7 @@ def test_turn_off_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned off test light' + assert response.speech['plain']['speech'] == 'Turned test light off' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' @@ -283,7 +283,7 @@ def test_turn_on_multiple_intent(hass): ) yield from hass.async_block_till_done() - assert response.speech['plain']['speech'] == 'Turned on test lights' + assert response.speech['plain']['speech'] == 'Turned test lights 2 on' assert len(calls) == 1 call = calls[0] assert call.domain == 'light' diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py index 010136ee0e7..06ad84e7a34 100644 --- a/tests/components/test_pilight.py +++ b/tests/components/test_pilight.py @@ -5,6 +5,8 @@ from unittest.mock import patch import socket from datetime import timedelta +import pytest + from homeassistant import core as ha from homeassistant.setup import setup_component from homeassistant.components import pilight @@ -63,6 +65,7 @@ class PilightDaemonSim: _LOGGER.error('PilightDaemonSim callback: ' + str(function)) +@pytest.mark.skip("Flaky") class TestPilight(unittest.TestCase): """Test the Pilight component.""" diff --git a/tests/components/test_rest_command.py b/tests/components/test_rest_command.py index 9dbea53cd64..3ddcfae8c01 100644 --- a/tests/components/test_rest_command.py +++ b/tests/components/test_rest_command.py @@ -222,21 +222,82 @@ class TestRestCommandComponent(object): assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == b'data' - def test_rest_command_content_type(self, aioclient_mock): - """Call a rest command with a content type.""" - data = { - 'payload': 'item', - 'content_type': 'text/plain' + def test_rest_command_headers(self, aioclient_mock): + """Call a rest command with custom headers and content types.""" + header_config_variations = { + rc.DOMAIN: { + 'no_headers_test': {}, + 'content_type_test': { + 'content_type': 'text/plain' + }, + 'headers_test': { + 'headers': { + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0' + } + }, + 'headers_and_content_type_test': { + 'headers': { + 'Accept': 'application/json' + }, + 'content_type': 'text/plain' + }, + 'headers_and_content_type_override_test': { + 'headers': { + 'Accept': 'application/json', + aiohttp.hdrs.CONTENT_TYPE: 'application/pdf' + }, + 'content_type': 'text/plain' + } + } } - self.config[rc.DOMAIN]['post_test'].update(data) - with assert_setup_component(4): - setup_component(self.hass, rc.DOMAIN, self.config) + # add common parameters + for variation in header_config_variations[rc.DOMAIN].values(): + variation.update({'url': self.url, 'method': 'post', + 'payload': 'test data'}) + with assert_setup_component(5): + setup_component(self.hass, rc.DOMAIN, header_config_variations) + + # provide post request data aioclient_mock.post(self.url, content=b'success') - self.hass.services.call(rc.DOMAIN, 'post_test', {}) - self.hass.block_till_done() + for test_service in ['no_headers_test', + 'content_type_test', + 'headers_test', + 'headers_and_content_type_test', + 'headers_and_content_type_override_test']: + self.hass.services.call(rc.DOMAIN, test_service, {}) - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == b'item' + self.hass.block_till_done() + assert len(aioclient_mock.mock_calls) == 5 + + # no_headers_test + assert aioclient_mock.mock_calls[0][3] is None + + # content_type_test + assert len(aioclient_mock.mock_calls[1][3]) == 1 + assert aioclient_mock.mock_calls[1][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + + # headers_test + assert len(aioclient_mock.mock_calls[2][3]) == 2 + assert aioclient_mock.mock_calls[2][3].get( + 'Accept') == 'application/json' + assert aioclient_mock.mock_calls[2][3].get( + 'User-Agent') == 'Mozilla/5.0' + + # headers_and_content_type_test + assert len(aioclient_mock.mock_calls[3][3]) == 2 + assert aioclient_mock.mock_calls[3][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + assert aioclient_mock.mock_calls[3][3].get( + 'Accept') == 'application/json' + + # headers_and_content_type_override_test + assert len(aioclient_mock.mock_calls[4][3]) == 2 + assert aioclient_mock.mock_calls[4][3].get( + aiohttp.hdrs.CONTENT_TYPE) == 'text/plain' + assert aioclient_mock.mock_calls[4][3].get( + 'Accept') == 'application/json' diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index f85030a6892..d0c129e512e 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -23,7 +23,6 @@ def websocket_client(loop, hass, test_client): client = loop.run_until_complete(test_client(hass.http.app)) ws = loop.run_until_complete(client.ws_connect(wapi.URL)) - auth_ok = loop.run_until_complete(ws.receive_json()) assert auth_ok['type'] == wapi.TYPE_AUTH_OK @@ -65,7 +64,7 @@ def mock_low_queue(): @asyncio.coroutine def test_auth_via_msg(no_auth_websocket_client): """Test authenticating.""" - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_AUTH, 'api_password': API_PASSWORD }) @@ -80,7 +79,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): """Test authenticating.""" with patch('homeassistant.components.websocket_api.process_wrong_login', return_value=mock_coro()) as mock_process_wrong_login: - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_AUTH, 'api_password': API_PASSWORD + 'wrong' }) @@ -95,7 +94,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): @asyncio.coroutine def test_pre_auth_only_auth_allowed(no_auth_websocket_client): """Verify that before authentication, only auth messages are allowed.""" - no_auth_websocket_client.send_json({ + yield from no_auth_websocket_client.send_json({ 'type': wapi.TYPE_CALL_SERVICE, 'domain': 'domain_test', 'service': 'test_service', @@ -113,7 +112,7 @@ def test_pre_auth_only_auth_allowed(no_auth_websocket_client): @asyncio.coroutine def test_invalid_message_format(websocket_client): """Test sending invalid JSON.""" - websocket_client.send_json({'type': 5}) + yield from websocket_client.send_json({'type': 5}) msg = yield from websocket_client.receive_json() @@ -126,7 +125,7 @@ def test_invalid_message_format(websocket_client): @asyncio.coroutine def test_invalid_json(websocket_client): """Test sending invalid JSON.""" - websocket_client.send_str('this is not JSON') + yield from websocket_client.send_str('this is not JSON') msg = yield from websocket_client.receive() @@ -155,7 +154,7 @@ def test_call_service(hass, websocket_client): hass.services.async_register('domain_test', 'test_service', service_call) - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_CALL_SERVICE, 'domain': 'domain_test', @@ -183,7 +182,7 @@ def test_subscribe_unsubscribe_events(hass, websocket_client): """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_SUBSCRIBE_EVENTS, 'event_type': 'test_event' @@ -212,7 +211,7 @@ def test_subscribe_unsubscribe_events(hass, websocket_client): assert event['data'] == {'hello': 'world'} assert event['origin'] == 'LOCAL' - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 6, 'type': wapi.TYPE_UNSUBSCRIBE_EVENTS, 'subscription': 5 @@ -233,7 +232,7 @@ def test_get_states(hass, websocket_client): hass.states.async_set('greeting.hello', 'world') hass.states.async_set('greeting.bye', 'universe') - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_STATES, }) @@ -256,7 +255,7 @@ def test_get_states(hass, websocket_client): @asyncio.coroutine def test_get_services(hass, websocket_client): """Test get_services command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_SERVICES, }) @@ -271,7 +270,7 @@ def test_get_services(hass, websocket_client): @asyncio.coroutine def test_get_config(hass, websocket_client): """Test get_config command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_CONFIG, }) @@ -296,7 +295,7 @@ def test_get_panels(hass, websocket_client): yield from hass.components.frontend.async_register_built_in_panel( 'map', 'Map', 'mdi:account-location') hass.data[frontend.DATA_JS_VERSION] = 'es5' - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_GET_PANELS, }) @@ -318,7 +317,7 @@ def test_get_panels(hass, websocket_client): @asyncio.coroutine def test_ping(websocket_client): """Test get_panels command.""" - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': 5, 'type': wapi.TYPE_PING, }) @@ -332,7 +331,7 @@ def test_ping(websocket_client): def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): """Test get_panels command.""" for idx in range(10): - websocket_client.send_json({ + yield from websocket_client.send_json({ 'id': idx + 1, 'type': wapi.TYPE_PING, }) diff --git a/tests/components/vacuum/test_xiaomi_miio.py b/tests/components/vacuum/test_xiaomi_miio.py index a4bf9f60dac..c4c1fb0e1b4 100644 --- a/tests/components/vacuum/test_xiaomi_miio.py +++ b/tests/components/vacuum/test_xiaomi_miio.py @@ -16,7 +16,7 @@ from homeassistant.components.vacuum.xiaomi_miio import ( ATTR_DO_NOT_DISTURB_START, ATTR_DO_NOT_DISTURB_END, ATTR_ERROR, ATTR_MAIN_BRUSH_LEFT, ATTR_SIDE_BRUSH_LEFT, ATTR_FILTER_LEFT, ATTR_CLEANING_COUNT, ATTR_CLEANED_TOTAL_AREA, ATTR_CLEANING_TOTAL_TIME, - CONF_HOST, CONF_NAME, CONF_TOKEN, PLATFORM, + CONF_HOST, CONF_NAME, CONF_TOKEN, SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP, SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL) from homeassistant.const import ( @@ -24,6 +24,8 @@ from homeassistant.const import ( STATE_ON) from homeassistant.setup import async_setup_component +PLATFORM = 'xiaomi_miio' + # calls made when device status is requested status_calls = [mock.call.Vacuum().status(), mock.call.Vacuum().consumable_status(), diff --git a/tests/conftest.py b/tests/conftest.py index 989785e72d5..8f0ca787721 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,8 @@ import requests_mock as _requests_mock from homeassistant import util from homeassistant.util import location -from tests.common import async_test_home_assistant, INSTANCES, \ - async_mock_mqtt_component +from tests.common import ( + async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -106,3 +106,24 @@ def mock_openzwave(): 'openzwave.group': base_mock.group, }): yield base_mock + + +@pytest.fixture +def mock_device_tracker_conf(): + """Prevent device tracker from reading/writing data.""" + devices = [] + + async def mock_update_config(path, id, entity): + devices.append(entity) + + with patch( + 'homeassistant.components.device_tracker' + '.DeviceTracker.async_update_config', + side_effect=mock_update_config + ), patch( + 'homeassistant.components.device_tracker.async_load_config', + side_effect=lambda *args: mock_coro(devices) + ), patch('homeassistant.components.device_tracker' + '.Device.set_vendor_for_mac'): + + yield devices diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 7aa7f6fa4d1..f5415ffe212 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -3,6 +3,7 @@ import asyncio import unittest import aiohttp +import pytest from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component @@ -12,6 +13,19 @@ from homeassistant.util.async import run_callback_threadsafe from tests.common import get_test_home_assistant +@pytest.fixture +def camera_client(hass, test_client): + """Fixture to fetch camera streams.""" + assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { + 'camera': { + 'name': 'config_test', + 'platform': 'mjpeg', + 'mjpeg_url': 'http://example.com/mjpeg_stream', + }})) + + yield hass.loop.run_until_complete(test_client(hass.http.app)) + + class TestHelpersAiohttpClient(unittest.TestCase): """Test homeassistant.helpers.aiohttp_client module.""" @@ -119,41 +133,38 @@ class TestHelpersAiohttpClient(unittest.TestCase): @asyncio.coroutine -def test_async_aiohttp_proxy_stream(aioclient_mock, hass, test_client): +def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com/mjpeg_stream', content=[ b'Frame1', b'Frame2', b'Frame3' ]) - result = yield from async_setup_component(hass, 'camera', { - 'camera': { - 'name': 'config_test', - 'platform': 'mjpeg', - 'mjpeg_url': 'http://example.com/mjpeg_stream', - }}) - assert result, 'Failed to setup camera' - - client = yield from test_client(hass.http.app) - - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 200 assert aioclient_mock.call_count == 1 body = yield from resp.text() assert body == 'Frame3Frame2Frame1' - aioclient_mock.clear_requests() - aioclient_mock.get( - 'http://example.com/mjpeg_stream', exc=asyncio.TimeoutError(), - content=[b'Frame1', b'Frame2', b'Frame3']) - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') +@asyncio.coroutine +def test_async_aiohttp_proxy_stream_timeout(aioclient_mock, camera_client): + """Test that it fetches the given url.""" + aioclient_mock.get( + 'http://example.com/mjpeg_stream', exc=asyncio.TimeoutError()) + + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 504 - aioclient_mock.clear_requests() - aioclient_mock.get( - 'http://example.com/mjpeg_stream', exc=aiohttp.ClientError(), - content=[b'Frame1', b'Frame2', b'Frame3']) - resp = yield from client.get('/api/camera_proxy_stream/camera.config_test') +@asyncio.coroutine +def test_async_aiohttp_proxy_stream_client_err(aioclient_mock, camera_client): + """Test that it fetches the given url.""" + aioclient_mock.get( + 'http://example.com/mjpeg_stream', exc=aiohttp.ClientError()) + + resp = yield from camera_client.get( + '/api/camera_proxy_stream/camera.config_test') assert resp.status == 502 diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 66f0597fc93..90be56bbc7c 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -164,6 +164,55 @@ def test_entity_ids(): ] +def test_entity_domain(): + """Test entity domain validation.""" + schema = vol.Schema(cv.entity_domain('sensor')) + + options = ( + 'invalid_entity', + 'cover.demo', + ) + + for value in options: + with pytest.raises(vol.MultipleInvalid): + print(value) + schema(value) + + assert schema('sensor.LIGHT') == 'sensor.light' + + +def test_entities_domain(): + """Test entities domain validation.""" + schema = vol.Schema(cv.entities_domain('sensor')) + + options = ( + None, + '', + 'invalid_entity', + ['sensor.light', 'cover.demo'], + ['sensor.light', 'sensor_invalid'], + ) + + for value in options: + with pytest.raises(vol.MultipleInvalid): + schema(value) + + options = ( + 'sensor.light', + ['SENSOR.light'], + ['sensor.light', 'sensor.demo'] + ) + for value in options: + schema(value) + + assert schema('sensor.LIGHT, sensor.demo ') == [ + 'sensor.light', 'sensor.demo' + ] + assert schema(['sensor.light', 'SENSOR.demo']) == [ + 'sensor.light', 'sensor.demo' + ] + + def test_ensure_list_csv(): """Test ensure_list_csv.""" schema = vol.Schema(cv.ensure_list_csv) @@ -453,6 +502,7 @@ def test_deprecated(caplog): ) deprecated_schema({'venus': True}) + # pylint: disable=len-as-condition assert len(caplog.records) == 0 deprecated_schema({'mars': True}) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 0681691ed67..8c085e4abb1 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -15,39 +15,13 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity) + MockEntity, MockEntityPlatform) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" PLATFORM = 'test_platform' -class MockEntityPlatform(entity_platform.EntityPlatform): - """Mock class with some mock defaults.""" - - def __init__( - self, hass, - logger=None, - domain=DOMAIN, - platform_name=PLATFORM, - scan_interval=timedelta(seconds=15), - parallel_updates=0, - entity_namespace=None, - async_entities_added_callback=lambda: None - ): - """Initialize a mock entity platform.""" - super().__init__( - hass=hass, - logger=logger, - domain=domain, - platform_name=platform_name, - scan_interval=scan_interval, - parallel_updates=parallel_updates, - entity_namespace=entity_namespace, - async_entities_added_callback=async_entities_added_callback, - ) - - class TestHelpersEntityPlatform(unittest.TestCase): """Test homeassistant.helpers.entity_component module.""" @@ -510,3 +484,30 @@ def test_registry_respect_entity_disabled(hass): yield from platform.async_add_entities([entity]) assert entity.entity_id is None assert hass.states.async_entity_ids() == [] + + +async def test_entity_registry_updates(hass): + """Test that updates on the entity registry update platform entities.""" + registry = mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='before update' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'before update' + + registry.async_update_entity('test_domain.world', name='after update') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('test_domain.world') + assert state.name == 'after update' diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py new file mode 100644 index 00000000000..a8d37a249bc --- /dev/null +++ b/tests/helpers/test_intent.py @@ -0,0 +1,12 @@ +"""Tests for the intent helpers.""" +from homeassistant.core import State +from homeassistant.helpers import intent + + +def test_async_match_state(): + """Test async_match_state helper.""" + state1 = State('light.kitchen', 'on') + state2 = State('switch.kitchen', 'on') + + state = intent.async_match_state(None, 'kitch', [state1, state2]) + assert state is state1 diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py new file mode 100644 index 00000000000..840f665f410 --- /dev/null +++ b/tests/helpers/test_translation.py @@ -0,0 +1,108 @@ +"""Test the translation helper.""" +# pylint: disable=protected-access +from os import path + +import homeassistant.helpers.translation as translation +from homeassistant.setup import async_setup_component + + +def test_flatten(): + """Test the flatten function.""" + data = { + "parent1": { + "child1": "data1", + "child2": "data2", + }, + "parent2": "data3", + } + + flattened = translation.flatten(data) + + assert flattened == { + "parent1.child1": "data1", + "parent1.child2": "data2", + "parent2": "data3", + } + + +async def test_component_translation_file(hass): + """Test the component translation file function.""" + assert await async_setup_component(hass, 'switch', { + 'switch': {'platform': 'test'} + }) + assert await async_setup_component(hass, 'test_standalone', { + 'test_standalone' + }) + assert await async_setup_component(hass, 'test_package', { + 'test_package' + }) + + assert path.normpath(translation.component_translation_file( + 'switch.test', 'en')) == path.normpath(hass.config.path( + 'custom_components', 'switch', '.translations', 'test.en.json')) + + assert path.normpath(translation.component_translation_file( + 'test_standalone', 'en')) == path.normpath(hass.config.path( + 'custom_components', '.translations', 'test_standalone.en.json')) + + assert path.normpath(translation.component_translation_file( + 'test_package', 'en')) == path.normpath(hass.config.path( + 'custom_components', 'test_package', '.translations', 'en.json')) + + +def test_load_translations_files(hass): + """Test the load translation files function.""" + # Test one valid and one invalid file + file1 = hass.config.path( + 'custom_components', 'switch', '.translations', 'test.en.json') + file2 = hass.config.path( + 'custom_components', 'switch', '.translations', 'invalid.json') + assert translation.load_translations_files({ + 'switch.test': file1, + 'invalid': file2 + }) == { + 'switch.test': { + 'state': { + 'string1': 'Value 1', + 'string2': 'Value 2', + } + }, + 'invalid': {}, + } + + +async def test_get_translations(hass): + """Test the get translations helper.""" + translations = await translation.async_get_translations(hass, 'en') + assert translations == {} + + assert await async_setup_component(hass, 'switch', { + 'switch': {'platform': 'test'} + }) + + translations = await translation.async_get_translations(hass, 'en') + assert translations == { + 'component.switch.state.string1': 'Value 1', + 'component.switch.state.string2': 'Value 2', + } + + translations = await translation.async_get_translations(hass, 'de') + assert translations == { + 'component.switch.state.string1': 'German Value 1', + 'component.switch.state.string2': 'German Value 2', + } + + # Test a partial translation + translations = await translation.async_get_translations(hass, 'es') + assert translations == { + 'component.switch.state.string1': 'Spanish Value 1', + 'component.switch.state.string2': 'Value 2', + } + + # Test that an untranslated language falls back to English. + translations = await translation.async_get_translations( + hass, 'invalid-language') + assert translations == { + 'component.switch.state.string1': 'Value 1', + 'component.switch.state.string2': 'Value 2', + } diff --git a/tests/mock/homekit.py b/tests/mock/homekit.py new file mode 100644 index 00000000000..2872fa59f19 --- /dev/null +++ b/tests/mock/homekit.py @@ -0,0 +1,133 @@ +"""Basic mock functions and objects related to the HomeKit component.""" +PATH_HOMEKIT = 'homeassistant.components.homekit' + + +def get_patch_paths(name=None): + """Return paths to mock 'add_preload_service'.""" + path_acc = PATH_HOMEKIT + '.accessories.add_preload_service' + path_file = PATH_HOMEKIT + '.' + str(name) + '.add_preload_service' + return (path_acc, path_file) + + +def mock_preload_service(acc, service, chars=None, opt_chars=None): + """Mock alternative for function 'add_preload_service'.""" + service = MockService(service) + if chars: + chars = chars if isinstance(chars, list) else [chars] + for char_name in chars: + service.add_characteristic(char_name) + if opt_chars: + opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars] + for opt_char_name in opt_chars: + service.add_characteristic(opt_char_name) + acc.add_service(service) + return service + + +class MockAccessory(): + """Define all attributes and methods for a MockAccessory.""" + + def __init__(self, name): + """Initialize a MockAccessory object.""" + self.display_name = name + self.services = [] + + def __repr__(self): + """Return a representation of a MockAccessory. Use for debugging.""" + serv_list = [serv.display_name for serv in self.services] + return "".format( + self.display_name, serv_list) + + def add_service(self, service): + """Add service to list of services.""" + self.services.append(service) + + def get_service(self, name): + """Retrieve service from service list or return new MockService.""" + for serv in self.services: + if serv.display_name == name: + return serv + serv = MockService(name) + self.add_service(serv) + return serv + + +class MockService(): + """Define all attributes and methods for a MockService.""" + + def __init__(self, name): + """Initialize a MockService object.""" + self.characteristics = [] + self.opt_characteristics = [] + self.display_name = name + + def __repr__(self): + """Return a representation of a MockService. Use for debugging.""" + char_list = [char.display_name for char in self.characteristics] + opt_char_list = [ + char.display_name for char in self.opt_characteristics] + return "".format( + self.display_name, char_list, opt_char_list) + + def add_characteristic(self, char): + """Add characteristic to char list.""" + self.characteristics.append(char) + + def add_opt_characteristic(self, char): + """Add characteristic to opt_char list.""" + self.opt_characteristics.append(char) + + def get_characteristic(self, name): + """Get char for char lists or return new MockChar.""" + for char in self.characteristics: + if char.display_name == name: + return char + for char in self.opt_characteristics: + if char.display_name == name: + return char + char = MockChar(name) + self.add_characteristic(char) + return char + + +class MockChar(): + """Define all attributes and methods for a MockChar.""" + + def __init__(self, name): + """Initialize a MockChar object.""" + self.display_name = name + self.properties = {} + self.value = None + self.type_id = None + self.setter_callback = None + + def __repr__(self): + """Return a representation of a MockChar. Use for debugging.""" + return "".format( + self.display_name, self.value) + + def set_value(self, value, should_notify=True, should_callback=True): + """Set value of char.""" + self.value = value + if self.setter_callback is not None and should_callback: + # pylint: disable=not-callable + self.setter_callback(value) + + def get_value(self): + """Get char value.""" + return self.value + + +class MockTypeLoader(): + """Define all attributes and methods for a MockTypeLoader.""" + + def __init__(self, class_type): + """Initialize a MockTypeLoader object.""" + self.class_type = class_type + + def get(self, name): + """Return a MockService or MockChar object.""" + if self.class_type == 'service': + return MockService(name) + elif self.class_type == 'char': + return MockChar(name) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 728e683a43a..677ed8de110 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -1,9 +1,12 @@ """Test check_config script.""" import asyncio import logging +import os # noqa: F401 pylint: disable=unused-import import unittest +from unittest.mock import patch import homeassistant.scripts.check_config as check_config +from homeassistant.config import YAML_CONFIG_FILE from homeassistant.loader import set_component from tests.common import patch_yaml_files, get_test_config_dir @@ -21,21 +24,14 @@ BASE_CONFIG = ( ) -def change_yaml_files(check_dict): - """Change the ['yaml_files'] property and remove the configuration path. - - Also removes other files like service.yaml that gets loaded. - """ +def normalize_yaml_files(check_dict): + """Remove configuration path from ['yaml_files'].""" root = get_test_config_dir() - keys = check_dict['yaml_files'].keys() - check_dict['yaml_files'] = [] - for key in sorted(keys): - if not key.startswith('/'): - check_dict['yaml_files'].append(key) - if key.startswith(root): - check_dict['yaml_files'].append('...' + key[len(root):]) + return [key.replace(root, '...') + for key in sorted(check_dict['yaml_files'].keys())] +# pylint: disable=unsubscriptable-object class TestCheckConfig(unittest.TestCase): """Tests for the homeassistant.scripts.check_config module.""" @@ -51,176 +47,165 @@ class TestCheckConfig(unittest.TestCase): asyncio.set_event_loop(asyncio.new_event_loop()) # Will allow seeing full diff - self.maxDiff = None + self.maxDiff = None # pylint: disable=invalid-name # pylint: disable=no-self-use,invalid-name - def test_config_platform_valid(self): + @patch('os.path.isfile', return_value=True) + def test_config_platform_valid(self, isfile_patch): """Test a valid platform setup.""" files = { - 'light.yaml': BASE_CONFIG + 'light:\n platform: demo', + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: demo', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('light.yaml')) - change_yaml_files(res) - self.assertDictEqual({ - 'components': {'light': [{'platform': 'demo'}], 'group': None}, - 'except': {}, - 'secret_cache': {}, - 'secrets': {}, - 'yaml_files': ['.../light.yaml'] - }, res) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [{'platform': 'demo'}] + assert res['except'] == {} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_config_component_platform_fail_validation(self): + @patch('os.path.isfile', return_value=True) + def test_config_component_platform_fail_validation(self, isfile_patch): """Test errors if component & platform not found.""" files = { - 'component.yaml': BASE_CONFIG + 'http:\n password: err123', + YAML_CONFIG_FILE: BASE_CONFIG + 'http:\n password: err123', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('component.yaml')) - change_yaml_files(res) - - self.assertDictEqual({}, res['components']) - res['except'].pop(check_config.ERROR_STR) - self.assertDictEqual( - {'http': {'password': 'err123'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../component.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'].keys() == {'http'} + assert res['except']['http'][1] == {'http': {'password': 'err123'}} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 files = { - 'platform.yaml': (BASE_CONFIG + 'mqtt:\n\n' - 'light:\n platform: mqtt_json'), + YAML_CONFIG_FILE: (BASE_CONFIG + 'mqtt:\n\n' + 'light:\n platform: mqtt_json'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('platform.yaml')) - change_yaml_files(res) - self.assertDictEqual( - {'mqtt': { - 'keepalive': 60, - 'port': 1883, - 'protocol': '3.1.1', - 'discovery': False, - 'discovery_prefix': 'homeassistant', - 'tls_version': 'auto', - }, - 'light': [], - 'group': None}, - res['components'] - ) - self.assertDictEqual( - {'light.mqtt_json': {'platform': 'mqtt_json'}}, - res['except'] - ) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../platform.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == { + 'homeassistant', 'light', 'mqtt'} + assert res['components']['light'] == [] + assert res['components']['mqtt'] == { + 'keepalive': 60, + 'port': 1883, + 'protocol': '3.1.1', + 'discovery': False, + 'discovery_prefix': 'homeassistant', + 'tls_version': 'auto', + } + assert res['except'].keys() == {'light.mqtt_json'} + assert res['except']['light.mqtt_json'][1] == { + 'platform': 'mqtt_json'} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_component_platform_not_found(self): + @patch('os.path.isfile', return_value=True) + def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" # Make sure they don't exist set_component('beer', None) - set_component('light.beer', None) files = { - 'badcomponent.yaml': BASE_CONFIG + 'beer:', - 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer', + YAML_CONFIG_FILE: BASE_CONFIG + 'beer:', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badcomponent.yaml')) - change_yaml_files(res) - self.assertDictEqual({}, res['components']) - self.assertDictEqual({ - check_config.ERROR_STR: [ - 'Component not found: beer', - 'Setup failed for beer: Component not found.'] - }, res['except']) - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badcomponent.yaml'], res['yaml_files']) + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant'} + assert res['except'] == { + check_config.ERROR_STR: ['Component not found: beer']} + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - res = check_config.check(get_test_config_dir('badplatform.yaml')) - change_yaml_files(res) - assert res['components'] == {'light': [], 'group': None} + set_component('light.beer', None) + files = { + YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: beer', + } + with patch_yaml_files(files): + res = check_config.check(get_test_config_dir()) + assert res['components'].keys() == {'homeassistant', 'light'} + assert res['components']['light'] == [] assert res['except'] == { check_config.ERROR_STR: [ 'Platform not found: light.beer', ]} - self.assertDictEqual({}, res['secret_cache']) - self.assertDictEqual({}, res['secrets']) - self.assertListEqual(['.../badplatform.yaml'], res['yaml_files']) + assert res['secret_cache'] == {} + assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 - def test_secrets(self): + @patch('os.path.isfile', return_value=True) + def test_secrets(self, isfile_patch): """Test secrets config checking method.""" + secrets_path = get_test_config_dir('secrets.yaml') + files = { - get_test_config_dir('secret.yaml'): ( - BASE_CONFIG + + get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG + ( 'http:\n' ' api_password: !secret http_pw'), - 'secrets.yaml': ('logger: debug\n' - 'http_pw: abc123'), + secrets_path: ( + 'logger: debug\n' + 'http_pw: abc123'), } with patch_yaml_files(files): - config_path = get_test_config_dir('secret.yaml') - secrets_path = get_test_config_dir('secrets.yaml') - res = check_config.check(config_path) - change_yaml_files(res) + res = check_config.check(get_test_config_dir(), True) - # convert secrets OrderedDict to dict for assertequal - for key, val in res['secret_cache'].items(): - res['secret_cache'][key] = dict(val) + assert res['except'] == {} + assert res['components'].keys() == {'homeassistant', 'http'} + assert res['components']['http'] == { + 'api_password': 'abc123', + 'cors_allowed_origins': [], + 'ip_ban_enabled': True, + 'login_attempts_threshold': -1, + 'server_host': '0.0.0.0', + 'server_port': 8123, + 'trusted_networks': [], + 'use_x_forwarded_for': False} + assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} + assert res['secrets'] == {'http_pw': 'abc123'} + assert normalize_yaml_files(res) == [ + '.../configuration.yaml', '.../secrets.yaml'] - self.assertDictEqual({ - 'components': {'http': {'api_password': 'abc123', - 'cors_allowed_origins': [], - 'ip_ban_enabled': True, - 'login_attempts_threshold': -1, - 'server_host': '0.0.0.0', - 'server_port': 8123, - 'trusted_networks': [], - 'use_x_forwarded_for': False}}, - 'except': {}, - 'secret_cache': {secrets_path: {'http_pw': 'abc123'}}, - 'secrets': {'http_pw': 'abc123'}, - 'yaml_files': ['.../secret.yaml', '.../secrets.yaml'] - }, res) - - def test_package_invalid(self): \ + @patch('os.path.isfile', return_value=True) + def test_package_invalid(self, isfile_patch): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'bad.yaml': BASE_CONFIG + (' packages:\n' - ' p1:\n' - ' group: ["a"]'), + YAML_CONFIG_FILE: BASE_CONFIG + ( + ' packages:\n' + ' p1:\n' + ' group: ["a"]'), } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('bad.yaml')) - change_yaml_files(res) + res = check_config.check(get_test_config_dir()) - err = res['except'].pop('homeassistant.packages.p1') - assert res['except'] == {} - assert err == {'group': ['a']} - assert res['yaml_files'] == ['.../bad.yaml'] - - assert res['components'] == {} + assert res['except'].keys() == {'homeassistant.packages.p1.group'} + assert res['except']['homeassistant.packages.p1.group'][1] == \ + {'group': ['a']} + assert len(res['except']) == 1 + assert res['components'].keys() == {'homeassistant'} + assert len(res['components']) == 1 assert res['secret_cache'] == {} assert res['secrets'] == {} + assert len(res['yaml_files']) == 1 def test_bootstrap_error(self): \ # pylint: disable=no-self-use,invalid-name """Test a valid platform setup.""" files = { - 'badbootstrap.yaml': BASE_CONFIG + 'automation: !include no.yaml', + YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', } with patch_yaml_files(files): - res = check_config.check(get_test_config_dir('badbootstrap.yaml')) - change_yaml_files(res) - + res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE)) err = res['except'].pop(check_config.ERROR_STR) assert len(err) == 1 assert res['except'] == {} - assert res['components'] == {} + assert res['components'] == {} # No components, load failed assert res['secret_cache'] == {} assert res['secrets'] == {} + assert res['yaml_files'] == {} diff --git a/tests/test_config.py b/tests/test_config.py index 541eaf4f79e..99c21493711 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -158,11 +158,11 @@ class TestConfig(unittest.TestCase): def test_load_yaml_config_preserves_key_order(self): """Test removal of library.""" with open(YAML_PATH, 'w') as f: - f.write('hello: 0\n') + f.write('hello: 2\n') f.write('world: 1\n') self.assertEqual( - [('hello', 0), ('world', 1)], + [('hello', 2), ('world', 1)], list(config_util.load_yaml_config_file(YAML_PATH).items())) @mock.patch('homeassistant.util.location.detect_location_info', diff --git a/tests/test_main.py b/tests/test_main.py index d3bd3cf751b..4518146c8cf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -22,20 +22,20 @@ def test_validate_python(mock_exit): mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 4, 1))): + new_callable=PropertyMock(return_value=(3, 4, 2))): main.validate_python() assert mock_exit.called is True mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 4, 2))): + new_callable=PropertyMock(return_value=(3, 5, 2))): main.validate_python() - assert mock_exit.called is False + assert mock_exit.called is True mock_exit.reset_mock() with patch('sys.version_info', - new_callable=PropertyMock(return_value=(3, 5, 1))): + new_callable=PropertyMock(return_value=(3, 5, 3))): main.validate_python() assert mock_exit.called is False diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 946e64af847..5f09e0bd83e 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -24,7 +24,7 @@ class TestRequirements: self.hass.stop() @mock.patch('os.path.dirname') - @mock.patch('homeassistant.util.package.running_under_virtualenv', + @mock.patch('homeassistant.util.package.is_virtual_env', return_value=True) @mock.patch('homeassistant.util.package.install_package', return_value=True) @@ -43,7 +43,7 @@ class TestRequirements: constraints=os.path.join('ha_package_path', CONSTRAINT_FILE)) @mock.patch('os.path.dirname') - @mock.patch('homeassistant.util.package.running_under_virtualenv', + @mock.patch('homeassistant.util.package.is_virtual_env', return_value=False) @mock.patch('homeassistant.util.package.install_package', return_value=True) diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py new file mode 100644 index 00000000000..b8499675ea2 --- /dev/null +++ b/tests/test_util/__init__.py @@ -0,0 +1 @@ +"""Tests for the test utilities.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d7033775a14..d661ffba477 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -1,11 +1,13 @@ """Aiohttp test utils.""" import asyncio from contextlib import contextmanager -import functools import json as _json +import re from unittest import mock -from urllib.parse import urlparse, parse_qs -import yarl +from urllib.parse import parse_qs + +from aiohttp import ClientSession +from yarl import URL from aiohttp.client_exceptions import ClientResponseError @@ -31,14 +33,17 @@ class AiohttpClientMocker: exc=None, cookies=None): """Mock a request.""" - if json: + if json is not None: text = _json.dumps(json) - if text: + if text is not None: content = text.encode('utf-8') if content is None: content = b'' + + if not isinstance(url, re._pattern_type): + url = URL(url) if params: - url = str(yarl.URL(url).with_query(params)) + url = url.with_query(params) self._mocks.append(AiohttpClientMockResponse( method, url, status, content, cookies, exc, headers)) @@ -74,13 +79,21 @@ class AiohttpClientMocker: self._cookies.clear() self.mock_calls.clear() - @asyncio.coroutine - # pylint: disable=unused-variable - def match_request(self, method, url, *, data=None, auth=None, params=None, - headers=None, allow_redirects=None, timeout=None, - json=None): + def create_session(self, loop): + """Create a ClientSession that is bound to this mocker.""" + session = ClientSession(loop=loop) + session._request = self.match_request + return session + + async def match_request(self, method, url, *, data=None, auth=None, + params=None, headers=None, allow_redirects=None, + timeout=None, json=None): """Match a request against pre-registered requests.""" data = data or json + url = URL(url) + if params: + url = url.with_query(params) + for response in self._mocks: if response.match_request(method, url, params): self.mock_calls.append((method, url, data, headers)) @@ -101,8 +114,6 @@ class AiohttpClientMockResponse: """Initialize a fake response.""" self.method = method self._url = url - self._url_parts = (None if hasattr(url, 'search') - else urlparse(url.lower())) self.status = status self.response = response self.exc = exc @@ -133,25 +144,17 @@ class AiohttpClientMockResponse: if method.lower() != self.method.lower(): return False - if params: - url = str(yarl.URL(url).with_query(params)) - # regular expression matching - if self._url_parts is None: - return self._url.search(url) is not None + if isinstance(self._url, re._pattern_type): + return self._url.search(str(url)) is not None - req = urlparse(url.lower()) - - if self._url_parts.scheme and req.scheme != self._url_parts.scheme: - return False - if self._url_parts.netloc and req.netloc != self._url_parts.netloc: - return False - if (req.path or '/') != (self._url_parts.path or '/'): + if (self._url.scheme != url.scheme or self._url.host != url.host or + self._url.path != url.path): return False # Ensure all query components in matcher are present in the request - request_qs = parse_qs(req.query) - matcher_qs = parse_qs(self._url_parts.query) + request_qs = parse_qs(url.query_string) + matcher_qs = parse_qs(self._url.query_string) for key, vals in matcher_qs.items(): for val in vals: try: @@ -207,12 +210,7 @@ def mock_aiohttp_client(): """Context manager to mock aiohttp client.""" mocker = AiohttpClientMocker() - with mock.patch('aiohttp.ClientSession') as mock_session: - instance = mock_session() - instance.request = mocker.match_request - - for method in ('get', 'post', 'put', 'options', 'delete'): - setattr(instance, method, - functools.partial(mocker.match_request, method)) - + with mock.patch( + 'homeassistant.helpers.aiohttp_client.async_create_clientsession', + side_effect=lambda hass, *args: mocker.create_session(hass.loop)): yield mocker diff --git a/tests/test_util/test_aiohttp.py b/tests/test_util/test_aiohttp.py new file mode 100644 index 00000000000..7f430e94beb --- /dev/null +++ b/tests/test_util/test_aiohttp.py @@ -0,0 +1,22 @@ +"""Tests for our aiohttp mocker.""" +from .aiohttp import AiohttpClientMocker + +import pytest + + +async def test_matching_url(): + """Test we can match urls.""" + mocker = AiohttpClientMocker() + mocker.get('http://example.com') + await mocker.match_request('get', 'http://example.com/') + + mocker.clear_requests() + + with pytest.raises(AssertionError): + await mocker.match_request('get', 'http://example.com/') + + mocker.clear_requests() + + mocker.get('http://example.com?a=1') + await mocker.match_request('get', 'http://example.com/', + params={'a': 1, 'b': 2}) diff --git a/tests/testing_config/custom_components/switch/.translations/test.de.json b/tests/testing_config/custom_components/switch/.translations/test.de.json new file mode 100644 index 00000000000..fad78b12d63 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.de.json @@ -0,0 +1,6 @@ +{ + "state": { + "string1": "German Value 1", + "string2": "German Value 2" + } +} diff --git a/tests/testing_config/custom_components/switch/.translations/test.en.json b/tests/testing_config/custom_components/switch/.translations/test.en.json new file mode 100644 index 00000000000..f4ce728af05 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.en.json @@ -0,0 +1,6 @@ +{ + "state": { + "string1": "Value 1", + "string2": "Value 2" + } +} diff --git a/tests/testing_config/custom_components/switch/.translations/test.es.json b/tests/testing_config/custom_components/switch/.translations/test.es.json new file mode 100644 index 00000000000..b3590a6d321 --- /dev/null +++ b/tests/testing_config/custom_components/switch/.translations/test.es.json @@ -0,0 +1,5 @@ +{ + "state": { + "string1": "Spanish Value 1" + } +} diff --git a/tests/testing_config/custom_components/test_package/__init__.py b/tests/testing_config/custom_components/test_package/__init__.py new file mode 100644 index 00000000000..528f056948b --- /dev/null +++ b/tests/testing_config/custom_components/test_package/__init__.py @@ -0,0 +1,7 @@ +"""Provide a mock package component.""" +DOMAIN = 'test_package' + + +def setup(hass, config): + """Mock a successful setup.""" + return True diff --git a/tests/testing_config/custom_components/test_standalone.py b/tests/testing_config/custom_components/test_standalone.py new file mode 100644 index 00000000000..f0d4ba7982b --- /dev/null +++ b/tests/testing_config/custom_components/test_standalone.py @@ -0,0 +1,7 @@ +"""Provide a mock standalone component.""" +DOMAIN = 'test_standalone' + + +def setup(hass, config): + """Mock a successful setup.""" + return True diff --git a/tests/util/test_color.py b/tests/util/test_color.py index 8b75e9e9e3f..86d303c23b7 100644 --- a/tests/util/test_color.py +++ b/tests/util/test_color.py @@ -2,6 +2,9 @@ import unittest import homeassistant.util.color as color_util +import pytest +import voluptuous as vol + class TestColorUtil(unittest.TestCase): """Test color util methods.""" @@ -44,16 +47,16 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((0, 0, 0), color_util.color_RGB_to_hsv(0, 0, 0)) - self.assertEqual((0, 0, 255), + self.assertEqual((0, 0, 100), color_util.color_RGB_to_hsv(255, 255, 255)) - self.assertEqual((43690, 255, 255), + self.assertEqual((240, 100, 100), color_util.color_RGB_to_hsv(0, 0, 255)) - self.assertEqual((21845, 255, 255), + self.assertEqual((120, 100, 100), color_util.color_RGB_to_hsv(0, 255, 0)) - self.assertEqual((0, 255, 255), + self.assertEqual((0, 100, 100), color_util.color_RGB_to_hsv(255, 0, 0)) def test_color_hsv_to_RGB(self): @@ -62,16 +65,16 @@ class TestColorUtil(unittest.TestCase): color_util.color_hsv_to_RGB(0, 0, 0)) self.assertEqual((255, 255, 255), - color_util.color_hsv_to_RGB(0, 0, 255)) + color_util.color_hsv_to_RGB(0, 0, 100)) self.assertEqual((0, 0, 255), - color_util.color_hsv_to_RGB(43690, 255, 255)) + color_util.color_hsv_to_RGB(240, 100, 100)) self.assertEqual((0, 255, 0), - color_util.color_hsv_to_RGB(21845, 255, 255)) + color_util.color_hsv_to_RGB(120, 100, 100)) self.assertEqual((255, 0, 0), - color_util.color_hsv_to_RGB(0, 255, 255)) + color_util.color_hsv_to_RGB(0, 100, 100)) def test_color_hsb_to_RGB(self): """Test color_hsb_to_RGB.""" @@ -92,19 +95,19 @@ class TestColorUtil(unittest.TestCase): def test_color_xy_to_hs(self): """Test color_xy_to_hs.""" - self.assertEqual((8609, 255), + self.assertEqual((47.294, 100), color_util.color_xy_to_hs(1, 1)) - self.assertEqual((6950, 32), + self.assertEqual((38.182, 12.941), color_util.color_xy_to_hs(.35, .35)) - self.assertEqual((62965, 255), + self.assertEqual((345.882, 100), color_util.color_xy_to_hs(1, 0)) - self.assertEqual((21845, 255), + self.assertEqual((120, 100), color_util.color_xy_to_hs(0, 1)) - self.assertEqual((40992, 255), + self.assertEqual((225.176, 100), color_util.color_xy_to_hs(0, 0)) def test_rgb_hex_to_rgb_list(self): @@ -150,10 +153,10 @@ class TestColorUtil(unittest.TestCase): self.assertEqual((72, 61, 139), color_util.color_name_to_rgb('darkslate blue')) - def test_color_name_to_rgb_unknown_name_default_white(self): + def test_color_name_to_rgb_unknown_name_raises_value_error(self): """Test color_name_to_rgb.""" - self.assertEqual((255, 255, 255), - color_util.color_name_to_rgb('not a color')) + with pytest.raises(ValueError): + color_util.color_name_to_rgb('not a color') def test_color_rgb_to_rgbw(self): """Test color_rgb_to_rgbw.""" @@ -280,3 +283,13 @@ class ColorTemperatureToRGB(unittest.TestCase): rgb = color_util.color_temperature_to_rgb(6500) self.assertGreater(rgb[0], rgb[1]) self.assertGreater(rgb[0], rgb[2]) + + +def test_get_color_in_voluptuous(): + """Test using the get method in color validation.""" + schema = vol.Schema(color_util.color_name_to_rgb) + + with pytest.raises(vol.Invalid): + schema('not a color') + + assert schema('red') == (255, 0, 0) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 94c8568dc47..c67b2aea448 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -6,7 +6,6 @@ import threading import homeassistant.util.logging as logging_util -@asyncio.coroutine def test_sensitive_data_filter(): """Test the logging sensitive data filter.""" log_filter = logging_util.HideSensitiveDataFilter('mock_sensitive') diff --git a/tests/util/test_package.py b/tests/util/test_package.py index ade374dad33..33db052f45a 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -68,8 +68,8 @@ def mock_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 homeassistant.util.package.is_virtual_env.""" + with patch('homeassistant.util.package.is_virtual_env') as mock: mock.return_value = True yield mock diff --git a/tox.ini b/tox.ini index fafc149f624..86acefe9b3f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py34, py35, py36, lint, requirements, typing +envlist = py35, py36, lint, requirements, typing skip_missing_interpreters = True [testenv]