diff --git a/.coveragerc b/.coveragerc index 48b45db347b..eae6498cd0a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -94,6 +94,12 @@ omit = homeassistant/components/envisalink.py homeassistant/components/*/envisalink.py + homeassistant/components/fritzbox.py + homeassistant/components/*/fritzbox.py + + homeassistant/components/eufy.py + homeassistant/components/*/eufy.py + homeassistant/components/gc100.py homeassistant/components/*/gc100.py @@ -106,6 +112,9 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py + homeassistant/components/homekit_controller/__init__.py + homeassistant/components/*/homekit_controller.py + homeassistant/components/homematic/__init__.py homeassistant/components/*/homematic.py @@ -190,8 +199,8 @@ omit = homeassistant/components/pilight.py homeassistant/components/*/pilight.py - homeassistant/components/qwikswitch.py - homeassistant/components/*/qwikswitch.py + homeassistant/components/switch/qwikswitch.py + homeassistant/components/light/qwikswitch.py homeassistant/components/rachio.py homeassistant/components/*/rachio.py @@ -639,7 +648,9 @@ omit = homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/serial.py + homeassistant/components/sensor/sht31.py homeassistant/components/sensor/shodan.py + homeassistant/components/sensor/sigfox.py homeassistant/components/sensor/simulated.py homeassistant/components/sensor/skybeacon.py homeassistant/components/sensor/sma.py @@ -669,6 +680,7 @@ omit = homeassistant/components/sensor/uber.py homeassistant/components/sensor/upnp.py homeassistant/components/sensor/ups.py + homeassistant/components/sensor/uscis.py homeassistant/components/sensor/vasttrafik.py homeassistant/components/sensor/viaggiatreno.py homeassistant/components/sensor/waqi.py diff --git a/.travis.yml b/.travis.yml index fce86348817..bf2d05bb185 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,7 +31,7 @@ script: travis_wait 30 tox --develop services: - docker before_deploy: - - docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 + - docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 deploy: skip_cleanup: true provider: script diff --git a/CODEOWNERS b/CODEOWNERS index 67aef6a248f..528716e174d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -63,6 +63,7 @@ homeassistant/components/media_player/xiaomi_tv.py @fattdev homeassistant/components/media_player/yamaha_musiccast.py @jalmeroth homeassistant/components/plant.py @ChristianKuehnel homeassistant/components/sensor/airvisual.py @bachya +homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel @@ -72,6 +73,7 @@ homeassistant/components/sensor/sma.py @kellerza homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen +homeassistant/components/sensor/upnp.py @dgomes homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/switch/rainmachine.py @bachya homeassistant/components/switch/tplink.py @rytilahti diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index aa966027922..deb1746c167 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -126,6 +126,10 @@ def get_arguments() -> argparse.Namespace: default=None, help='Log file to write to. If not set, CONFIG/home-assistant.log ' 'is used') + parser.add_argument( + '--log-no-color', + action='store_true', + help="Disable color logs") parser.add_argument( '--runner', action='store_true', @@ -259,13 +263,14 @@ def setup_and_run_hass(config_dir: str, hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, - log_file=args.log_file) + log_file=args.log_file, log_no_color=args.log_no_color) else: config_file = ensure_config_file(config_dir) print('Config directory:', config_dir) hass = bootstrap.from_config_file( config_file, verbose=args.verbose, skip_pip=args.skip_pip, - log_rotate_days=args.log_rotate_days, log_file=args.log_file) + log_rotate_days=args.log_rotate_days, log_file=args.log_file, + log_no_color=args.log_no_color) if hass is None: return None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 00822d93299..e0962568a66 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -42,7 +42,8 @@ def from_config_dict(config: Dict[str, Any], verbose: bool = False, skip_pip: bool = False, log_rotate_days: Any = None, - log_file: Any = None) \ + log_file: Any = None, + log_no_color: bool = False) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -60,7 +61,7 @@ def from_config_dict(config: Dict[str, Any], hass = hass.loop.run_until_complete( async_from_config_dict( config, hass, config_dir, enable_log, verbose, skip_pip, - log_rotate_days, log_file) + log_rotate_days, log_file, log_no_color) ) return hass @@ -74,7 +75,8 @@ def async_from_config_dict(config: Dict[str, Any], verbose: bool = False, skip_pip: bool = False, log_rotate_days: Any = None, - log_file: Any = None) \ + log_file: Any = None, + log_no_color: bool = False) \ -> Optional[core.HomeAssistant]: """Try to configure Home Assistant from a configuration dictionary. @@ -84,7 +86,8 @@ def async_from_config_dict(config: Dict[str, Any], start = time() if enable_log: - async_enable_logging(hass, verbose, log_rotate_days, log_file) + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) core_config = config.get(core.DOMAIN, {}) @@ -164,7 +167,8 @@ def from_config_file(config_path: str, verbose: bool = False, skip_pip: bool = True, log_rotate_days: Any = None, - log_file: Any = None): + log_file: Any = None, + log_no_color: bool = False): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -176,7 +180,8 @@ def from_config_file(config_path: str, # run task hass = hass.loop.run_until_complete( async_from_config_file( - config_path, hass, verbose, skip_pip, log_rotate_days, log_file) + config_path, hass, verbose, skip_pip, + log_rotate_days, log_file, log_no_color) ) return hass @@ -188,7 +193,8 @@ def async_from_config_file(config_path: str, verbose: bool = False, skip_pip: bool = True, log_rotate_days: Any = None, - log_file: Any = None): + log_file: Any = None, + log_no_color: bool = False): """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -199,7 +205,8 @@ def async_from_config_file(config_path: str, hass.config.config_dir = config_dir yield from async_mount_local_lib_path(config_dir, hass.loop) - async_enable_logging(hass, verbose, log_rotate_days, log_file) + async_enable_logging(hass, verbose, log_rotate_days, log_file, + log_no_color) try: config_dict = yield from hass.async_add_job( @@ -216,40 +223,51 @@ def async_from_config_file(config_path: str, @core.callback -def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False, - log_rotate_days=None, log_file=None) -> None: +def async_enable_logging(hass: core.HomeAssistant, + verbose: bool = False, + log_rotate_days=None, + log_file=None, + log_no_color: bool = False) -> None: """Set up the logging. This method must be run in the event loop. """ - logging.basicConfig(level=logging.INFO) fmt = ("%(asctime)s %(levelname)s (%(threadName)s) " "[%(name)s] %(message)s") - colorfmt = "%(log_color)s{}%(reset)s".format(fmt) datefmt = '%Y-%m-%d %H:%M:%S' + if not log_no_color: + try: + from colorlog import ColoredFormatter + # basicConfig must be called after importing colorlog in order to + # ensure that the handlers it sets up wraps the correct streams. + logging.basicConfig(level=logging.INFO) + + colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + logging.getLogger().handlers[0].setFormatter(ColoredFormatter( + colorfmt, + datefmt=datefmt, + reset=True, + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'green', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red', + } + )) + except ImportError: + pass + + # If the above initialization failed for any reason, setup the default + # formatting. If the above succeeds, this wil result in a no-op. + logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger('requests').setLevel(logging.WARNING) logging.getLogger('urllib3').setLevel(logging.WARNING) logging.getLogger('aiohttp.access').setLevel(logging.WARNING) - try: - from colorlog import ColoredFormatter - logging.getLogger().handlers[0].setFormatter(ColoredFormatter( - colorfmt, - datefmt=datefmt, - reset=True, - log_colors={ - 'DEBUG': 'cyan', - 'INFO': 'green', - 'WARNING': 'yellow', - 'ERROR': 'red', - 'CRITICAL': 'red', - } - )) - except ImportError: - pass - # Log errors to a file if we have write access to file or config dir if log_file is None: err_log_path = hass.config.path(ERROR_LOG_FILENAME) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 08918c77f01..2f56bb7c2b5 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['abodepy==0.12.3'] +REQUIREMENTS = ['abodepy==0.13.1'] _LOGGER = logging.getLogger(__name__) @@ -27,6 +27,7 @@ CONF_ATTRIBUTION = "Data provided by goabode.com" CONF_POLLING = 'polling' DOMAIN = 'abode' +DEFAULT_CACHEDB = './abodepy_cache.pickle' NOTIFICATION_ID = 'abode_notification' NOTIFICATION_TITLE = 'Abode Security Setup' @@ -87,12 +88,13 @@ ABODE_PLATFORMS = [ class AbodeSystem(object): """Abode System class.""" - def __init__(self, username, password, name, polling, exclude, lights): + def __init__(self, username, password, cache, + name, polling, exclude, lights): """Initialize the system.""" import abodepy self.abode = abodepy.Abode( username, password, auto_login=True, get_devices=True, - get_automations=True) + get_automations=True, cache_path=cache) self.name = name self.polling = polling self.exclude = exclude @@ -129,8 +131,9 @@ def setup(hass, config): lights = conf.get(CONF_LIGHTS) try: + cache = hass.config.path(DEFAULT_CACHEDB) hass.data[DOMAIN] = AbodeSystem( - username, password, name, polling, exclude, lights) + username, password, cache, name, polling, exclude, lights) except (AbodeException, ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Abode: %s", str(ex)) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 707f8d02958..c5c68f1af40 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1471,6 +1471,7 @@ async def async_api_adjust_target_temp(hass, config, request, entity): async def async_api_set_thermostat_mode(hass, config, request, entity): """Process a set thermostat mode request.""" mode = request[API_PAYLOAD]['thermostatMode'] + mode = mode if isinstance(mode, str) else mode['value'] operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) # Work around a pylint false positive due to diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py index e7af5af988b..0abf6eb1064 100644 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -46,6 +46,7 @@ class BMWConnectedDriveSensor(BinarySensorDevice): self._vehicle = vehicle self._attribute = attribute self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._device_class = device_class self._state = None @@ -55,6 +56,11 @@ class BMWConnectedDriveSensor(BinarySensorDevice): """Data update is triggered from BMWConnectedDriveEntity.""" return False + @property + def unique_id(self): + """Return the unique ID of the binary sensor.""" + return self._unique_id + @property def name(self): """Return the name of the binary sensor.""" diff --git a/homeassistant/components/binary_sensor/hive.py b/homeassistant/components/binary_sensor/hive.py index 2d4cbd8d070..46dd1b193e8 100644 --- a/homeassistant/components/binary_sensor/hive.py +++ b/homeassistant/components/binary_sensor/hive.py @@ -32,6 +32,7 @@ class HiveBinarySensorEntity(BinarySensorDevice): self.device_type = hivedevice["HA_DeviceType"] self.node_device_type = hivedevice["Hive_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -52,6 +53,11 @@ class HiveBinarySensorEntity(BinarySensorDevice): """Return the name of the binary sensor.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def is_on(self): """Return true if the binary sensor is on.""" @@ -61,3 +67,5 @@ class HiveBinarySensorEntity(BinarySensorDevice): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/homeassistant/components/binary_sensor/maxcube.py b/homeassistant/components/binary_sensor/maxcube.py index 1043004243a..c131de5420a 100644 --- a/homeassistant/components/binary_sensor/maxcube.py +++ b/homeassistant/components/binary_sensor/maxcube.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/maxcube/ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.components.maxcube import DATA_KEY from homeassistant.const import STATE_UNKNOWN _LOGGER = logging.getLogger(__name__) @@ -15,16 +15,17 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): """Iterate through all MAX! Devices and add window shutters.""" - cube = hass.data[MAXCUBE_HANDLE].cube devices = [] + for handler in hass.data[DATA_KEY].values(): + cube = handler.cube + for device in cube.devices: + name = "{} {}".format( + cube.room_by_id(device.room_id).name, device.name) - for device in cube.devices: - name = "{} {}".format( - cube.room_by_id(device.room_id).name, device.name) - - # Only add Window Shutters - if cube.is_windowshutter(device): - devices.append(MaxCubeShutter(hass, name, device.rf_address)) + # Only add Window Shutters + if cube.is_windowshutter(device): + devices.append( + MaxCubeShutter(handler, name, device.rf_address)) if devices: add_devices(devices) @@ -33,12 +34,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MaxCubeShutter(BinarySensorDevice): """Representation of a MAX! Cube Binary Sensor device.""" - def __init__(self, hass, name, rf_address): + def __init__(self, handler, name, rf_address): """Initialize MAX! Cube BinarySensorDevice.""" self._name = name self._sensor_type = 'window' self._rf_address = rf_address - self._cubehandle = hass.data[MAXCUBE_HANDLE] + self._cubehandle = handler self._state = STATE_UNKNOWN @property diff --git a/homeassistant/components/bmw_connected_drive.py b/homeassistant/components/bmw_connected_drive/__init__.py similarity index 55% rename from homeassistant/components/bmw_connected_drive.py rename to homeassistant/components/bmw_connected_drive/__init__.py index 48452b6d79b..347bab6f529 100644 --- a/homeassistant/components/bmw_connected_drive.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'bmw_connected_drive' CONF_REGION = 'region' - +ATTR_VIN = 'vin' ACCOUNT_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, @@ -35,35 +35,40 @@ CONFIG_SCHEMA = vol.Schema({ }, }, extra=vol.ALLOW_EXTRA) +SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_VIN): cv.string, +}) + BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor'] UPDATE_INTERVAL = 5 # in minutes +SERVICE_UPDATE_STATE = 'update_state' -def setup(hass, config): +_SERVICE_MAP = { + 'light_flash': 'trigger_remote_light_flash', + 'sound_horn': 'trigger_remote_horn', + 'activate_air_conditioning': 'trigger_remote_air_conditioning', +} + + +def setup(hass, config: dict): """Set up the BMW connected drive components.""" accounts = [] for name, account_config in config[DOMAIN].items(): - username = account_config[CONF_USERNAME] - password = account_config[CONF_PASSWORD] - region = account_config[CONF_REGION] - _LOGGER.debug('Adding new account %s', name) - bimmer = BMWConnectedDriveAccount(username, password, region, name) - accounts.append(bimmer) - - # update every UPDATE_INTERVAL minutes, starting now - # this should even out the load on the servers - - now = datetime.datetime.now() - track_utc_time_change( - hass, bimmer.update, - minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), - second=now.second) + accounts.append(setup_account(account_config, hass, name)) hass.data[DOMAIN] = accounts - for account in accounts: - account.update() + def _update_all(call) -> None: + """Update all BMW accounts.""" + for cd_account in hass.data[DOMAIN]: + cd_account.update() + + # Service to manually trigger updates for all accounts. + hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all) + + _update_all(None) for component in BMW_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -71,6 +76,48 @@ def setup(hass, config): return True +def setup_account(account_config: dict, hass, name: str) \ + -> 'BMWConnectedDriveAccount': + """Set up a new BMWConnectedDriveAccount based on the config.""" + username = account_config[CONF_USERNAME] + password = account_config[CONF_PASSWORD] + region = account_config[CONF_REGION] + _LOGGER.debug('Adding new account %s', name) + cd_account = BMWConnectedDriveAccount(username, password, region, name) + + def execute_service(call): + """Execute a service for a vehicle. + + This must be a member function as we need access to the cd_account + object here. + """ + vin = call.data[ATTR_VIN] + vehicle = cd_account.account.get_vehicle(vin) + if not vehicle: + _LOGGER.error('Could not find a vehicle for VIN "%s"!', vin) + return + function_name = _SERVICE_MAP[call.service] + function_call = getattr(vehicle.remote_services, function_name) + function_call() + + # register the remote services + for service in _SERVICE_MAP: + hass.services.register( + DOMAIN, service, + execute_service, + schema=SERVICE_SCHEMA) + + # update every UPDATE_INTERVAL minutes, starting now + # this should even out the load on the servers + now = datetime.datetime.now() + track_utc_time_change( + hass, cd_account.update, + minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL), + second=now.second) + + return cd_account + + class BMWConnectedDriveAccount(object): """Representation of a BMW vehicle.""" diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml new file mode 100644 index 00000000000..3c180271919 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -0,0 +1,42 @@ +# Describes the format for available services for bmw_connected_drive +# +# The services related to locking/unlocking are implemented in the lock +# component to avoid redundancy. + +light_flash: + description: > + Flash the lights of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +sound_horn: + description: > + Sound the horn of the vehicle. The vehicle is identified via the vin + (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +activate_air_conditioning: + description: > + Start the air conditioning of the vehicle. What exactly is started here + depends on the type of vehicle. It might range from just ventilation over + auxilary heating to real air conditioning. The vehicle is identified via + the vin (see below). + fields: + vin: + description: > + The vehicle identification number (VIN) of the vehicle, 17 characters + example: WBANXXXXXX1234567 + +update_state: + description: > + Fetch the last state of the vehicles of all your accounts from the BMW + server. This does *not* trigger an update from the vehicle, it just gets + the data from the BMW servers. This service does not require any attributes. \ No newline at end of file diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index a8763e8ca9e..6c26c65ebe7 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -11,6 +11,7 @@ from datetime import timedelta from homeassistant.components.calendar import CalendarEventDevice from homeassistant.components.google import ( CONF_CAL_ID, CONF_ENTITIES, CONF_TRACK, TOKEN_FILE, + CONF_IGNORE_AVAILABILITY, CONF_SEARCH, GoogleCalendarService) from homeassistant.util import Throttle, dt @@ -18,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_GOOGLE_SEARCH_PARAMS = { 'orderBy': 'startTime', - 'maxResults': 1, + 'maxResults': 5, 'singleEvents': True, } @@ -45,18 +46,22 @@ class GoogleCalendarEventDevice(CalendarEventDevice): def __init__(self, hass, calendar_service, calendar, data): """Create the Calendar event device.""" self.data = GoogleCalendarData(calendar_service, calendar, - data.get('search', None)) + data.get(CONF_SEARCH), + data.get(CONF_IGNORE_AVAILABILITY)) + super().__init__(hass, data) class GoogleCalendarData(object): """Class to utilize calendar service object to get next event.""" - def __init__(self, calendar_service, calendar_id, search=None): + def __init__(self, calendar_service, calendar_id, search, + ignore_availability): """Set up how we are going to search the google calendar.""" self.calendar_service = calendar_service self.calendar_id = calendar_id self.search = search + self.ignore_availability = ignore_availability self.event = None @Throttle(MIN_TIME_BETWEEN_UPDATES) @@ -80,5 +85,17 @@ class GoogleCalendarData(object): result = events.list(**params).execute() items = result.get('items', []) - self.event = items[0] if len(items) == 1 else None + + new_event = None + for item in items: + if (not self.ignore_availability + and 'transparency' in item.keys()): + if item['transparency'] == 'opaque': + new_event = item + break + else: + new_event = item + break + + self.event = new_event return True diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml index 61ff4345fbe..ebf0c7b1591 100644 --- a/homeassistant/components/calendar/services.yaml +++ b/homeassistant/components/calendar/services.yaml @@ -1,21 +1,26 @@ # Describes the format for available calendar services -todoist: - new_task: - description: Create a new task and add it to a project. - fields: - content: - description: The name of the task (Required). - example: Pick up the mail - project: - description: The name of the project this task should belong to. Defaults to Inbox (Optional). - example: Errands - labels: - description: Any labels that you want to apply to this task, separated by a comma (Optional). - example: Chores,Deliveries - priority: - description: The priority of this task, from 1 (normal) to 4 (urgent) (Optional). - example: 2 - due_date: - description: The day this task is due, in format YYYY-MM-DD (Optional). - example: "2018-04-01" +todoist_new_task: + description: Create a new task and add it to a project. + fields: + content: + description: The name of the task. + example: Pick up the mail + project: + description: The name of the project this task should belong to. Defaults to Inbox. + example: Errands + labels: + description: Any labels that you want to apply to this task, separated by a comma. + example: Chores,Deliveries + priority: + description: The priority of this task, from 1 (normal) to 4 (urgent). + example: 2 + due_date_string: + description: The day this task is due, in natural language. + example: "tomorrow" + due_date_lang: + description: The language of due_date_string. + example: "en" + due_date: + description: The day this task is due, in format YYYY-MM-DD. + example: "2018-04-01" diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index 02840c7d0ee..b70e44456db 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -41,6 +41,14 @@ CONTENT = 'content' DESCRIPTION = 'description' # Calendar Platform: Used in the '_get_date()' method DATETIME = 'dateTime' +# Service Call: When is this task due (in natural language)? +DUE_DATE_STRING = 'due_date_string' +# Service Call: The language of DUE_DATE_STRING +DUE_DATE_LANG = 'due_date_lang' +# Service Call: The available options of DUE_DATE_LANG +DUE_DATE_VALID_LANGS = ['en', 'da', 'pl', 'zh', 'ko', 'de', + 'pt', 'ja', 'it', 'fr', 'sv', 'ru', + 'es', 'nl'] # Attribute: When is this task due? # Service Call: When is this task due? DUE_DATE = 'due_date' @@ -83,7 +91,11 @@ NEW_TASK_SERVICE_SCHEMA = vol.Schema({ vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower), vol.Optional(LABELS): cv.ensure_list_csv, vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)), - vol.Optional(DUE_DATE): cv.string, + + vol.Exclusive(DUE_DATE_STRING, 'due_date'): cv.string, + vol.Optional(DUE_DATE_LANG): + vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)), + vol.Exclusive(DUE_DATE, 'due_date'): cv.string, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -186,6 +198,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if PRIORITY in call.data: item.update(priority=call.data[PRIORITY]) + if DUE_DATE_STRING in call.data: + item.update(date_string=call.data[DUE_DATE_STRING]) + + if DUE_DATE_LANG in call.data: + item.update(date_lang=call.data[DUE_DATE_LANG]) + if DUE_DATE in call.data: due_date = dt.parse_datetime(call.data[DUE_DATE]) if due_date is None: diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 7ea23f4fd65..550d4035ddd 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -40,6 +40,7 @@ STATE_HEAT = 'heat' STATE_COOL = 'cool' STATE_IDLE = 'idle' STATE_AUTO = 'auto' +STATE_MANUAL = 'manual' STATE_DRY = 'dry' STATE_FAN_ONLY = 'fan_only' STATE_ECO = 'eco' diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py new file mode 100755 index 00000000000..839da8c9d53 --- /dev/null +++ b/homeassistant/components/climate/fritzbox.py @@ -0,0 +1,153 @@ +""" +Support for AVM Fritz!Box smarthome thermostate devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/climate.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED) +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) +from homeassistant.const import ( + ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS) + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) + +OPERATION_LIST = [STATE_HEAT, STATE_ECO] + +MIN_TEMPERATURE = 8 +MAX_TEMPERATURE = 28 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome thermostat platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_thermostat: + devices.append(FritzboxThermostat(device, fritz)) + + add_devices(devices) + + +class FritzboxThermostat(ClimateDevice): + """The thermostat class for Fritzbox smarthome thermostates.""" + + def __init__(self, device, fritz): + """Initialize the thermostat.""" + self._device = device + self._fritz = fritz + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + @property + def available(self): + """Return if thermostat is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def temperature_unit(self): + """Return the unit of measurement that is used.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return precision 0.5.""" + return PRECISION_HALVES + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_OPERATION_MODE in kwargs: + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + self.set_operation_mode(operation_mode) + elif ATTR_TEMPERATURE in kwargs: + temperature = kwargs.get(ATTR_TEMPERATURE) + self._device.set_target_temperature(temperature) + + @property + def current_operation(self): + """Return the current operation mode.""" + if self._target_temperature == self._comfort_temperature: + return STATE_HEAT + elif self._target_temperature == self._eco_temperature: + return STATE_ECO + return STATE_MANUAL + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + def set_operation_mode(self, operation_mode): + """Set new operation mode.""" + if operation_mode == STATE_HEAT: + self.set_temperature(temperature=self._comfort_temperature) + elif operation_mode == STATE_ECO: + self.set_temperature(temperature=self._eco_temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return MIN_TEMPERATURE + + @property + def max_temp(self): + """Return the maximum temperature.""" + return MAX_TEMPERATURE + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attrs = { + ATTR_STATE_DEVICE_LOCKED: self._device.device_lock, + ATTR_STATE_LOCKED: self._device.lock, + ATTR_STATE_BATTERY_LOW: self._device.battery_low, + } + return attrs + + def update(self): + """Update the data from the thermostat.""" + try: + self._device.update() + self._current_temperature = self._device.actual_temperature + self._target_temperature = self._device.target_temperature + self._comfort_temperature = self._device.comfort_temperature + self._eco_temperature = self._device.eco_temperature + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzbox connection error: %s", ex) + self._fritz.login() diff --git a/homeassistant/components/climate/hive.py b/homeassistant/components/climate/hive.py index 760ef131049..eb3aecae3a1 100644 --- a/homeassistant/components/climate/hive.py +++ b/homeassistant/components/climate/hive.py @@ -38,7 +38,10 @@ class HiveClimateEntity(ClimateDevice): self.node_id = hivedevice["Hive_NodeID"] self.node_name = hivedevice["Hive_NodeName"] self.device_type = hivedevice["HA_DeviceType"] + if self.device_type == "Heating": + self.thermostat_node_id = hivedevice["Thermostat_NodeID"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -71,6 +74,11 @@ class HiveClimateEntity(ClimateDevice): friendly_name = "Hot Water" return friendly_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def temperature_unit(self): """Return the unit of measurement.""" @@ -175,4 +183,9 @@ class HiveClimateEntity(ClimateDevice): def update(self): """Update all Node data from Hive.""" + node = self.node_id + if self.device_type == "Heating": + node = self.thermostat_node_id + self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes(node) diff --git a/homeassistant/components/climate/maxcube.py b/homeassistant/components/climate/maxcube.py index 067d11437b2..712ebb4f4ce 100644 --- a/homeassistant/components/climate/maxcube.py +++ b/homeassistant/components/climate/maxcube.py @@ -10,7 +10,7 @@ import logging from homeassistant.components.climate import ( ClimateDevice, STATE_AUTO, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) -from homeassistant.components.maxcube import MAXCUBE_HANDLE +from homeassistant.components.maxcube import DATA_KEY from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE _LOGGER = logging.getLogger(__name__) @@ -24,16 +24,16 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE def setup_platform(hass, config, add_devices, discovery_info=None): """Iterate through all MAX! Devices and add thermostats.""" - cube = hass.data[MAXCUBE_HANDLE].cube - devices = [] + for handler in hass.data[DATA_KEY].values(): + cube = handler.cube + for device in cube.devices: + name = '{} {}'.format( + cube.room_by_id(device.room_id).name, device.name) - for device in cube.devices: - name = '{} {}'.format( - cube.room_by_id(device.room_id).name, device.name) - - if cube.is_thermostat(device) or cube.is_wallthermostat(device): - devices.append(MaxCubeClimate(hass, name, device.rf_address)) + if cube.is_thermostat(device) or cube.is_wallthermostat(device): + devices.append( + MaxCubeClimate(handler, name, device.rf_address)) if devices: add_devices(devices) @@ -42,14 +42,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class MaxCubeClimate(ClimateDevice): """MAX! Cube ClimateDevice.""" - def __init__(self, hass, name, rf_address): + def __init__(self, handler, name, rf_address): """Initialize MAX! Cube ClimateDevice.""" self._name = name self._unit_of_measurement = TEMP_CELSIUS self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST, STATE_VACATION] self._rf_address = rf_address - self._cubehandle = hass.data[MAXCUBE_HANDLE] + self._cubehandle = handler @property def supported_features(self): diff --git a/homeassistant/components/climate/modbus.py b/homeassistant/components/climate/modbus.py new file mode 100644 index 00000000000..7d392e5a40f --- /dev/null +++ b/homeassistant/components/climate/modbus.py @@ -0,0 +1,148 @@ +""" +Platform for a Generic Modbus Thermostat. + +This uses a setpoint and process +value within the controller, so both the current temperature register and the +target temperature register need to be configured. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.modbus/ +""" +import logging +import struct + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_SLAVE, ATTR_TEMPERATURE) +from homeassistant.components.climate import ( + ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) + +import homeassistant.components.modbus as modbus +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['modbus'] + +# Parameters not defined by homeassistant.const +CONF_TARGET_TEMP = 'target_temp_register' +CONF_CURRENT_TEMP = 'current_temp_register' +CONF_DATA_TYPE = 'data_type' +CONF_COUNT = 'data_count' +CONF_PRECISION = 'precision' + +DATA_TYPE_INT = 'int' +DATA_TYPE_UINT = 'uint' +DATA_TYPE_FLOAT = 'float' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SLAVE): cv.positive_int, + vol.Required(CONF_TARGET_TEMP): cv.positive_int, + vol.Required(CONF_CURRENT_TEMP): cv.positive_int, + vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): + vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]), + vol.Optional(CONF_COUNT, default=2): cv.positive_int, + vol.Optional(CONF_PRECISION, default=1): cv.positive_int +}) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Modbus Thermostat Platform.""" + name = config.get(CONF_NAME) + modbus_slave = config.get(CONF_SLAVE) + target_temp_register = config.get(CONF_TARGET_TEMP) + current_temp_register = config.get(CONF_CURRENT_TEMP) + data_type = config.get(CONF_DATA_TYPE) + count = config.get(CONF_COUNT) + precision = config.get(CONF_PRECISION) + + add_devices([ModbusThermostat(name, modbus_slave, + target_temp_register, current_temp_register, + data_type, count, precision)], True) + + +class ModbusThermostat(ClimateDevice): + """Representation of a Modbus Thermostat.""" + + def __init__(self, name, modbus_slave, target_temp_register, + current_temp_register, data_type, count, precision): + """Initialize the unit.""" + self._name = name + self._slave = modbus_slave + self._target_temperature_register = target_temp_register + self._current_temperature_register = current_temp_register + self._target_temperature = None + self._current_temperature = None + self._data_type = data_type + self._count = int(count) + self._precision = precision + self._structure = '>f' + + data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'}, + DATA_TYPE_UINT: {1: 'H', 2: 'I', 4: 'Q'}, + DATA_TYPE_FLOAT: {1: 'e', 2: 'f', 4: 'd'}} + + self._structure = '>{}'.format(data_types[self._data_type] + [self._count]) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS + + def update(self): + """Update Target & Current Temperature.""" + self._target_temperature = self.read_register( + self._target_temperature_register) + self._current_temperature = self.read_register( + self._current_temperature_register) + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + target_temperature = kwargs.get(ATTR_TEMPERATURE) + if target_temperature is None: + return + byte_string = struct.pack(self._structure, target_temperature) + register_value = struct.unpack('>h', byte_string[0:2])[0] + + try: + self.write_register(self._target_temperature_register, + register_value) + except AttributeError as ex: + _LOGGER.error(ex) + + def read_register(self, register): + """Read holding register using the modbus hub slave.""" + try: + result = modbus.HUB.read_holding_registers(self._slave, register, + self._count) + except AttributeError as ex: + _LOGGER.error(ex) + byte_string = b''.join( + [x.to_bytes(2, byteorder='big') for x in result.registers]) + val = struct.unpack(self._structure, byte_string)[0] + register_value = format(val, '.{}f'.format(self._precision)) + return register_value + + def write_register(self, register, value): + """Write register using the modbus hub slave.""" + modbus.HUB.write_registers(self._slave, register, [value, 0]) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index d11f6890a7b..0a5344fdf98 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -187,6 +187,11 @@ class NestThermostat(ClimateDevice): device_mode = operation_mode elif operation_mode == STATE_AUTO: device_mode = NEST_MODE_HEAT_COOL + else: + device_mode = STATE_OFF + _LOGGER.error( + "An error occurred while setting device mode. " + "Invalid operation mode: %s", operation_mode) self.device.mode = device_mode @property diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index aa42325b75b..d2aa918eda2 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,11 +1,10 @@ """Http views to control the config manager.""" import asyncio -import voluptuous as vol - -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.helpers.data_entry_flow import ( + FlowManagerIndexView, FlowManagerResourceView) REQUIREMENTS = ['voluptuous-serialize==1'] @@ -16,15 +15,17 @@ def async_setup(hass): """Enable the Home Assistant views.""" hass.http.register_view(ConfigManagerEntryIndexView) hass.http.register_view(ConfigManagerEntryResourceView) - hass.http.register_view(ConfigManagerFlowIndexView) - hass.http.register_view(ConfigManagerFlowResourceView) + hass.http.register_view( + ConfigManagerFlowIndexView(hass.config_entries.flow)) + hass.http.register_view( + ConfigManagerFlowResourceView(hass.config_entries.flow)) hass.http.register_view(ConfigManagerAvailableFlowView) return True def _prepare_json(result): """Convert result for JSON.""" - if result['type'] != config_entries.RESULT_TYPE_FORM: + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: return result import voluptuous_serialize @@ -78,7 +79,7 @@ class ConfigManagerEntryResourceView(HomeAssistantView): return self.json(result) -class ConfigManagerFlowIndexView(HomeAssistantView): +class ConfigManagerFlowIndexView(FlowManagerIndexView): """View to create config flows.""" url = '/api/config/config_entries/flow' @@ -94,81 +95,16 @@ class ConfigManagerFlowIndexView(HomeAssistantView): hass = request.app['hass'] return self.json([ - flow for flow in hass.config_entries.flow.async_progress() - if flow['source'] != config_entries.SOURCE_USER]) - - @RequestDataValidator(vol.Schema({ - vol.Required('domain'): str, - })) - @asyncio.coroutine - def post(self, request, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_init( - data['domain']) - except config_entries.UnknownHandler: - return self.json_message('Invalid handler specified', 404) - except config_entries.UnknownStep: - return self.json_message('Handler does not support init', 400) - - result = _prepare_json(result) - - return self.json(result) + flw for flw in hass.config_entries.flow.async_progress() + if flw['source'] != data_entry_flow.SOURCE_USER]) -class ConfigManagerFlowResourceView(HomeAssistantView): +class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" url = '/api/config/config_entries/flow/{flow_id}' name = 'api:config:config_entries:flow:resource' - @asyncio.coroutine - def get(self, request, flow_id): - """Get the current state of a flow.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - result = _prepare_json(result) - - return self.json(result) - - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - @asyncio.coroutine - def post(self, request, flow_id, data): - """Handle a POST request.""" - hass = request.app['hass'] - - try: - result = yield from hass.config_entries.flow.async_configure( - flow_id, data) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - except vol.Invalid: - return self.json_message('User input malformed', 400) - - result = _prepare_json(result) - - return self.json(result) - - @asyncio.coroutine - def delete(self, request, flow_id): - """Cancel a flow in progress.""" - hass = request.app['hass'] - - try: - hass.config_entries.flow.async_abort(flow_id) - except config_entries.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - - return self.json_message('Flow aborted') - class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index d68021d7db3..028a7a0c9fc 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -18,30 +18,31 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_DISTANCE_SENSOR = "distance_sensor" -ATTR_DOOR_STATE = "door_state" -ATTR_SIGNAL_STRENGTH = "wifi_signal" +ATTR_DISTANCE_SENSOR = 'distance_sensor' +ATTR_DOOR_STATE = 'door_state' +ATTR_SIGNAL_STRENGTH = 'wifi_signal' -CONF_DEVICEKEY = "device_key" +CONF_DEVICE_ID = 'device_id' +CONF_DEVICE_KEY = 'device_key' DEFAULT_NAME = 'OpenGarage' DEFAULT_PORT = 80 -STATE_CLOSING = "closing" -STATE_OFFLINE = "offline" -STATE_OPENING = "opening" -STATE_STOPPED = "stopped" +STATE_CLOSING = 'closing' +STATE_OFFLINE = 'offline' +STATE_OPENING = 'opening' +STATE_STOPPED = 'stopped' STATES_MAP = { 0: STATE_CLOSED, - 1: STATE_OPEN + 1: STATE_OPEN, } COVER_SCHEMA = vol.Schema({ - vol.Required(CONF_DEVICEKEY): cv.string, + vol.Required(CONF_DEVICE_KEY): cv.string, vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -50,7 +51,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up OpenGarage covers.""" + """Set up the OpenGarage covers.""" covers = [] devices = config.get(CONF_COVERS) @@ -59,8 +60,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): CONF_NAME: device_config.get(CONF_NAME), CONF_HOST: device_config.get(CONF_HOST), CONF_PORT: device_config.get(CONF_PORT), - "device_id": device_config.get(CONF_DEVICE, device_id), - CONF_DEVICEKEY: device_config.get(CONF_DEVICEKEY) + CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id), + CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY) } covers.append(OpenGarageCover(hass, args)) @@ -79,8 +80,8 @@ class OpenGarageCover(CoverDevice): self.hass = hass self._name = args[CONF_NAME] self.device_id = args['device_id'] - self._devicekey = args[CONF_DEVICEKEY] - self._state = STATE_UNKNOWN + self._device_key = args[CONF_DEVICE_KEY] + self._state = None self._state_before_move = None self.dist = None self.signal = None @@ -138,8 +139,8 @@ class OpenGarageCover(CoverDevice): try: status = self._get_status() if self._name is None: - if status["name"] is not None: - self._name = status["name"] + if status['name'] is not None: + self._name = status['name'] state = STATES_MAP.get(status.get('door'), STATE_UNKNOWN) if self._state_before_move is not None: if self._state_before_move != state: @@ -152,7 +153,7 @@ class OpenGarageCover(CoverDevice): self.signal = status.get('rssi') self.dist = status.get('dist') self._available = True - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = STATE_OFFLINE @@ -166,15 +167,15 @@ class OpenGarageCover(CoverDevice): def _push_button(self): """Send commands to API.""" url = '{}/cc?dkey={}&click=1'.format( - self.opengarage_url, self._devicekey) + self.opengarage_url, self._device_key) try: response = requests.get(url, timeout=10).json() - if response["result"] == 2: - _LOGGER.error("Unable to control %s: device_key is incorrect.", + if response['result'] == 2: + _LOGGER.error("Unable to control %s: Device key is incorrect", self._name) self._state = self._state_before_move self._state_before_move = None - except (requests.exceptions.RequestException) as ex: + except requests.exceptions.RequestException as ex: _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s", dict(reason=ex)) self._state = self._state_before_move diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 6fb8e92e051..c99076de851 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Tahoma covers.""" + """Set up the Tahoma covers.""" controller = hass.data[TAHOMA_DOMAIN]['controller'] devices = [] for device in hass.data[TAHOMA_DOMAIN]['devices']['cover']: diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 69165dbbbaf..7ea68af01c1 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -18,6 +18,7 @@ "no_key": "Couldn't get an API key" }, "abort": { + "already_configured": "Bridge is already configured", "no_bridges": "No deCONZ bridges discovered", "one_instance_only": "Component only supports one deCONZ instance" } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 85ba271ec3a..064725eda95 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -4,28 +4,20 @@ Support for deCONZ devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/deconz/ """ -import logging - import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.discovery import SERVICE_DECONZ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers import discovery, aiohttp_client -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util.json import load_json, save_json +from homeassistant.helpers import ( + aiohttp_client, discovery, config_validation as cv) +from homeassistant.util.json import load_json -REQUIREMENTS = ['pydeconz==35'] +# Loading the config flow file will register the flow +from .config_flow import configured_hosts +from .const import CONFIG_FILE, DATA_DECONZ_ID, DOMAIN, _LOGGER -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'deconz' -DATA_DECONZ_ID = 'deconz_entities' - -CONFIG_FILE = 'deconz.conf' +REQUIREMENTS = ['pydeconz==36'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -46,46 +38,38 @@ SERVICE_SCHEMA = vol.Schema({ }) -CONFIG_INSTRUCTIONS = """ -Unlock your deCONZ gateway to register with Home Assistant. - -1. [Go to deCONZ system settings](http://{}:{}/edit_system.html) -2. Press "Unlock Gateway" button - -[deCONZ platform documentation](https://home-assistant.io/components/deconz/) -""" - - async def async_setup(hass, config): - """Set up services and configuration for deCONZ component.""" - result = False - config_file = await hass.async_add_job( - load_json, hass.config.path(CONFIG_FILE)) - - async def async_deconz_discovered(service, discovery_info): - """Call when deCONZ gateway has been found.""" - deconz_config = {} - deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) - deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) - await async_request_configuration(hass, config, deconz_config) - - if config_file: - result = await async_setup_deconz(hass, config, config_file) - - if not result and DOMAIN in config and CONF_HOST in config[DOMAIN]: - deconz_config = config[DOMAIN] - if CONF_API_KEY in deconz_config: - result = await async_setup_deconz(hass, config, deconz_config) - else: - await async_request_configuration(hass, config, deconz_config) - return True - - if not result: - discovery.async_listen(hass, SERVICE_DECONZ, async_deconz_discovered) + """Load configuration for deCONZ component. + Discovery has loaded the component if DOMAIN is not present in config. + """ + if DOMAIN in config: + deconz_config = None + config_file = await hass.async_add_job( + load_json, hass.config.path(CONFIG_FILE)) + if config_file: + deconz_config = config_file + elif CONF_HOST in config[DOMAIN]: + deconz_config = config[DOMAIN] + if deconz_config and not configured_hosts(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, source='import', data=deconz_config + )) return True +async def async_setup_entry(hass, entry): + """Set up a deCONZ bridge for a config entry.""" + if DOMAIN in hass.data: + _LOGGER.error( + "Config entry failed since one deCONZ instance already exists") + return False + result = await async_setup_deconz(hass, None, entry.data) + if result: + return True + return False + + async def async_setup_deconz(hass, config, deconz_config): """Set up a deCONZ session. @@ -94,8 +78,8 @@ async def async_setup_deconz(hass, config, deconz_config): """ _LOGGER.debug("deCONZ config %s", deconz_config) from pydeconz import DeconzSession - websession = async_get_clientsession(hass) - deconz = DeconzSession(hass.loop, websession, **deconz_config) + session = aiohttp_client.async_get_clientsession(hass) + deconz = DeconzSession(hass.loop, session, **deconz_config) result = await deconz.async_load_parameters() if result is False: _LOGGER.error("Failed to communicate with deCONZ") @@ -152,121 +136,3 @@ async def async_setup_deconz(hass, config, deconz_config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deconz_shutdown) return True - - -async def async_request_configuration(hass, config, deconz_config): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - - async def async_configuration_callback(data): - """Set up actions to do when our configuration callback is called.""" - from pydeconz.utils import async_get_api_key - websession = async_get_clientsession(hass) - api_key = await async_get_api_key(websession, **deconz_config) - if api_key: - deconz_config[CONF_API_KEY] = api_key - result = await async_setup_deconz(hass, config, deconz_config) - if result: - await hass.async_add_job( - save_json, hass.config.path(CONFIG_FILE), deconz_config) - configurator.async_request_done(request_id) - return - else: - configurator.async_notify_errors( - request_id, "Couldn't load configuration.") - else: - configurator.async_notify_errors( - request_id, "Couldn't get an API key.") - return - - instructions = CONFIG_INSTRUCTIONS.format( - deconz_config[CONF_HOST], deconz_config[CONF_PORT]) - - request_id = configurator.async_request_config( - "deCONZ", async_configuration_callback, - description=instructions, - entity_picture="/static/images/logo_deconz.jpeg", - submit_caption="I have unlocked the gateway", - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class DeconzFlowHandler(config_entries.ConfigFlowHandler): - """Handle a deCONZ config flow.""" - - VERSION = 1 - - def __init__(self): - """Initialize the deCONZ flow.""" - self.bridges = [] - self.deconz_config = {} - - async def async_step_init(self, user_input=None): - """Handle a flow start.""" - from pydeconz.utils import async_discovery - - if DOMAIN in self.hass.data: - return self.async_abort( - reason='one_instance_only' - ) - - if user_input is not None: - for bridge in self.bridges: - if bridge[CONF_HOST] == user_input[CONF_HOST]: - self.deconz_config = bridge - return await self.async_step_link() - - session = aiohttp_client.async_get_clientsession(self.hass) - self.bridges = await async_discovery(session) - - if len(self.bridges) == 1: - self.deconz_config = self.bridges[0] - return await self.async_step_link() - elif len(self.bridges) > 1: - hosts = [] - for bridge in self.bridges: - hosts.append(bridge[CONF_HOST]) - return self.async_show_form( - step_id='init', - data_schema=vol.Schema({ - vol.Required(CONF_HOST): vol.In(hosts) - }) - ) - - return self.async_abort( - reason='no_bridges' - ) - - async def async_step_link(self, user_input=None): - """Attempt to link with the deCONZ bridge.""" - from pydeconz.utils import async_get_api_key - errors = {} - - if user_input is not None: - session = aiohttp_client.async_get_clientsession(self.hass) - api_key = await async_get_api_key(session, **self.deconz_config) - if api_key: - self.deconz_config[CONF_API_KEY] = api_key - return self.async_create_entry( - title='deCONZ', - data=self.deconz_config - ) - else: - errors['base'] = 'no_key' - - return self.async_show_form( - step_id='link', - errors=errors, - ) - - -async def async_setup_entry(hass, entry): - """Set up a bridge for a config entry.""" - if DOMAIN in hass.data: - _LOGGER.error( - "Config entry failed since one deCONZ instance already exists") - return False - result = await async_setup_deconz(hass, None, entry.data) - if result: - return True - return False diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py new file mode 100644 index 00000000000..e900782ea65 --- /dev/null +++ b/homeassistant/components/deconz/config_flow.py @@ -0,0 +1,139 @@ +"""Config flow to configure deCONZ component.""" + +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.helpers import aiohttp_client +from homeassistant.util.json import load_json + +from .const import CONFIG_FILE, DOMAIN + + +@callback +def configured_hosts(hass): + """Return a set of the configured hosts.""" + return set(entry.data['host'] for entry + in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class DeconzFlowHandler(data_entry_flow.FlowHandler): + """Handle a deCONZ config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the deCONZ config flow.""" + self.bridges = [] + self.deconz_config = {} + + async def async_step_init(self, user_input=None): + """Handle a deCONZ config flow start.""" + from pydeconz.utils import async_discovery + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + + if user_input is not None: + for bridge in self.bridges: + if bridge[CONF_HOST] == user_input[CONF_HOST]: + self.deconz_config = bridge + return await self.async_step_link() + + session = aiohttp_client.async_get_clientsession(self.hass) + self.bridges = await async_discovery(session) + + if len(self.bridges) == 1: + self.deconz_config = self.bridges[0] + return await self.async_step_link() + elif len(self.bridges) > 1: + hosts = [] + for bridge in self.bridges: + hosts.append(bridge[CONF_HOST]) + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): vol.In(hosts) + }) + ) + + return self.async_abort( + reason='no_bridges' + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the deCONZ bridge.""" + from pydeconz.utils import async_get_api_key, async_get_bridgeid + errors = {} + + if user_input is not None: + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + session = aiohttp_client.async_get_clientsession(self.hass) + api_key = await async_get_api_key(session, **self.deconz_config) + if api_key: + self.deconz_config[CONF_API_KEY] = api_key + if 'bridgeid' not in self.deconz_config: + self.deconz_config['bridgeid'] = await async_get_bridgeid( + session, **self.deconz_config) + return self.async_create_entry( + title='deCONZ-' + self.deconz_config['bridgeid'], + data=self.deconz_config + ) + errors['base'] = 'no_key' + + return self.async_show_form( + step_id='link', + errors=errors, + ) + + async def async_step_discovery(self, discovery_info): + """Prepare configuration for a discovered deCONZ bridge. + + This flow is triggered by the discovery component. + """ + deconz_config = {} + deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) + deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) + deconz_config['bridgeid'] = discovery_info.get('serial') + + config_file = await self.hass.async_add_job( + load_json, self.hass.config.path(CONFIG_FILE)) + if config_file and \ + config_file[CONF_HOST] == deconz_config[CONF_HOST] and \ + CONF_API_KEY in config_file: + deconz_config[CONF_API_KEY] = config_file[CONF_API_KEY] + + return await self.async_step_import(deconz_config) + + async def async_step_import(self, import_config): + """Import a deCONZ bridge as a config entry. + + This flow is triggered by `async_setup` for configured bridges. + This flow is also triggered by `async_step_discovery`. + + This will execute for any bridge that does not have a + config entry yet (based on host). + + If an API key is provided, we will create an entry. + Otherwise we will delegate to `link` step which + will ask user to link the bridge. + """ + from pydeconz.utils import async_get_bridgeid + + if configured_hosts(self.hass): + return self.async_abort(reason='one_instance_only') + elif CONF_API_KEY not in import_config: + self.deconz_config = import_config + return await self.async_step_link() + + if 'bridgeid' not in import_config: + session = aiohttp_client.async_get_clientsession(self.hass) + import_config['bridgeid'] = await async_get_bridgeid( + session, **import_config) + return self.async_create_entry( + title='deCONZ-' + import_config['bridgeid'], + data=import_config + ) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py new file mode 100644 index 00000000000..c5820c971f6 --- /dev/null +++ b/homeassistant/components/deconz/const.py @@ -0,0 +1,8 @@ +"""Constants for the deCONZ component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.deconz') + +DOMAIN = 'deconz' +CONFIG_FILE = 'deconz.conf' +DATA_DECONZ_ID = 'deconz_entities' diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 69165dbbbaf..7ea68af01c1 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -18,6 +18,7 @@ "no_key": "Couldn't get an API key" }, "abort": { + "already_configured": "Bridge is already configured", "no_bridges": "No deCONZ bridges discovered", "one_instance_only": "Component only supports one deCONZ instance" } diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 45f0e51a214..b24f7784faf 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -605,6 +605,17 @@ class DeviceScanner(object): """ return self.hass.async_add_job(self.get_device_name, device) + def get_extra_attributes(self, device: str) -> dict: + """Get the extra attributes of a device.""" + raise NotImplementedError() + + def async_get_extra_attributes(self, device: str) -> Any: + """Get the extra attributes of a device. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.get_extra_attributes, device) + def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta): """Load devices from YAML configuration file.""" @@ -690,10 +701,20 @@ def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType, host_name = yield from scanner.async_get_device_name(mac) seen.add(mac) + try: + extra_attributes = (yield from + scanner.async_get_extra_attributes(mac)) + except NotImplementedError: + extra_attributes = dict() + kwargs = { 'mac': mac, 'host_name': host_name, - 'source_type': SOURCE_TYPE_ROUTER + 'source_type': SOURCE_TYPE_ROUTER, + 'attributes': { + 'scanner': scanner.__class__.__name__, + **extra_attributes + } } zone_home = hass.states.get(zone.ENTITY_ID_HOME) diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py index 2267bb51944..f36afc622ee 100644 --- a/homeassistant/components/device_tracker/bmw_connected_drive.py +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -48,8 +48,11 @@ class BMWDeviceTracker(object): return _LOGGER.debug('Updating %s', dev_id) - + attrs = { + 'vin': self.vehicle.vin, + } self._see( dev_id=dev_id, host_name=self.vehicle.name, - gps=self.vehicle.state.gps_position, icon='mdi:car' + gps=self.vehicle.state.gps_position, attributes=attrs, + icon='mdi:car' ) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 9e257616361..d1e59293365 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.util import slugify _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['locationsharinglib==0.4.0'] +REQUIREMENTS = ['locationsharinglib==1.2.1'] CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 154fc3d2a63..a6a67749f76 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -176,7 +176,7 @@ class MikrotikScanner(DeviceScanner): for device in device_names if device.get('mac-address')} - if self.wireless_exist: + if self.wireless_exist or self.capsman_exist: self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py index 23cb7ea8f9d..3c090e8cd3b 100644 --- a/homeassistant/components/device_tracker/nmap_tracker.py +++ b/homeassistant/components/device_tracker/nmap_tracker.py @@ -80,6 +80,8 @@ class NmapDeviceScanner(DeviceScanner): """Scan for new devices and return a list with found device IDs.""" self._update_info() + _LOGGER.debug("Nmap last results %s", self.last_results) + return [device.mac for device in self.last_results] def get_device_name(self, device): @@ -91,6 +93,13 @@ class NmapDeviceScanner(DeviceScanner): return filter_named[0] return None + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_ip = next(( + result.ip for result in self.last_results + if result.mac == device), None) + return {'ip': filter_ip} + def _update_info(self): """Scan the network for devices. diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index dd12df7b070..3d7ef5cef6e 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -103,6 +103,9 @@ class UbusDeviceScanner(DeviceScanner): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: self._generate_mac2name() + if self.mac2name is None: + # Generation of mac2name dictionary failed + return None name = self.mac2name.get(device.upper(), None) return name diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index d8a52aaaeb4..b7efe65dd01 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -122,3 +122,9 @@ class UnifiScanner(DeviceScanner): name = client.get('name') or client.get('hostname') _LOGGER.debug("Device mac %s name %s", device, name) return name + + def get_extra_attributes(self, device): + """Return the extra attributes of the device.""" + client = self._clients.get(device, {}) + _LOGGER.debug("Device mac %s attributes %s", device, client) + return client diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 61568892388..c5769253657 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -20,7 +20,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), }) -REQUIREMENTS = ['python-miio==0.3.9'] +REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] def get_scanner(hass, config): @@ -41,7 +41,7 @@ def get_scanner(hass, config): device_info.model, device_info.firmware_version, device_info.hardware_version) - scanner = XiaomiMiioDeviceScanner(hass, device) + scanner = XiaomiMiioDeviceScanner(device) except DeviceException as ex: _LOGGER.error("Device unavailable or token incorrect: %s", ex) @@ -51,7 +51,7 @@ def get_scanner(hass, config): class XiaomiMiioDeviceScanner(DeviceScanner): """This class queries a Xiaomi Mi WiFi Repeater.""" - def __init__(self, hass, device): + def __init__(self, device): """Initialize the scanner.""" self.device = device diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 01ef36b778b..f0ebcba8366 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,7 +13,7 @@ import os import voluptuous as vol -from homeassistant import config_entries +from homeassistant import data_entry_flow from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.3.0'] +REQUIREMENTS = ['netdisco==1.3.1'] DOMAIN = 'discovery' @@ -40,8 +40,10 @@ SERVICE_HUE = 'philips_hue' SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' +SERVICE_HOMEKIT = 'homekit' CONFIG_ENTRY_HANDLERS = { + SERVICE_DECONZ: 'deconz', SERVICE_HUE: 'hue', } @@ -56,7 +58,6 @@ SERVICE_HANDLERS = { SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), - SERVICE_DECONZ: ('deconz', None), SERVICE_DAIKIN: ('daikin', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), 'google_cast': ('media_player', 'cast'), @@ -77,15 +78,23 @@ SERVICE_HANDLERS = { 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), 'songpal': ('media_player', 'songpal'), + 'kodi': ('media_player', 'kodi'), +} + +OPTIONAL_SERVICE_HANDLERS = { + SERVICE_HOMEKIT: ('homekit_controller', None), } CONF_IGNORE = 'ignore' +CONF_ENABLE = 'enable' CONFIG_SCHEMA = vol.Schema({ vol.Required(DOMAIN): vol.Schema({ vol.Optional(CONF_IGNORE, default=[]): vol.All(cv.ensure_list, [ - vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]) + vol.In(list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS))]), + vol.Optional(CONF_ENABLE, default=[]): + vol.All(cv.ensure_list, [vol.In(OPTIONAL_SERVICE_HANDLERS)]) }), }, extra=vol.ALLOW_EXTRA) @@ -104,6 +113,9 @@ async def async_setup(hass, config): # Platforms ignore by config ignored_platforms = config[DOMAIN][CONF_IGNORE] + # Optional platforms enabled by config + enabled_platforms = config[DOMAIN][CONF_ENABLE] + async def new_service_found(service, info): """Handle a new service if one is found.""" if service in ignored_platforms: @@ -119,13 +131,16 @@ async def async_setup(hass, config): if service in CONFIG_ENTRY_HANDLERS: await hass.config_entries.flow.async_init( CONFIG_ENTRY_HANDLERS[service], - source=config_entries.SOURCE_DISCOVERY, + source=data_entry_flow.SOURCE_DISCOVERY, data=info ) return comp_plat = SERVICE_HANDLERS.get(service) + if not comp_plat and service in enabled_platforms: + comp_plat = OPTIONAL_SERVICE_HANDLERS[service] + # We do not know how to handle this service. if not comp_plat: logger.info("Unknown service discovered: %s %s", service, info) diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index d1503dc74dc..9c29cea704c 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -16,7 +16,7 @@ from homeassistant.const import CONF_API_KEY from homeassistant.util import Throttle from homeassistant.util.json import save_json -REQUIREMENTS = ['python-ecobee-api==0.0.17'] +REQUIREMENTS = ['python-ecobee-api==0.0.18'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py new file mode 100644 index 00000000000..733aa0adbfe --- /dev/null +++ b/homeassistant/components/eufy.py @@ -0,0 +1,77 @@ +""" +Support for Eufy devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/eufy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, \ + CONF_DEVICES, CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, CONF_NAME +from homeassistant.helpers import discovery + +import homeassistant.helpers.config_validation as cv + + +REQUIREMENTS = ['lakeside==0.5'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'eufy' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_ADDRESS): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Optional(CONF_NAME): cv.string +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_DEVICES, default=[]): vol.All(cv.ensure_list, + [DEVICE_SCHEMA]), + vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string, + vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + +EUFY_DISPATCH = { + 'T1011': 'light', + 'T1012': 'light', + 'T1013': 'light', + 'T1201': 'switch', + 'T1202': 'switch', + 'T1211': 'switch' +} + + +def setup(hass, config): + """Set up Eufy devices.""" + # pylint: disable=import-error + import lakeside + + if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: + data = lakeside.get_devices(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD]) + for device in data: + kind = device['type'] + if kind not in EUFY_DISPATCH: + continue + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + for device_info in config[DOMAIN][CONF_DEVICES]: + kind = device_info['type'] + if kind not in EUFY_DISPATCH: + continue + device = {} + device['address'] = device_info['address'] + device['code'] = device_info['access_token'] + device['type'] = device_info['type'] + device['name'] = device_info['name'] + discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device, + config) + + return True diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py new file mode 100755 index 00000000000..a3c35aaa597 --- /dev/null +++ b/homeassistant/components/fritzbox.py @@ -0,0 +1,83 @@ +""" +Support for AVM Fritz!Box smarthome devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/fritzbox/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import discovery + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['pyfritzhome==0.3.7'] + +SUPPORTED_DOMAINS = ['climate', 'switch'] + +DOMAIN = 'fritzbox' + +ATTR_STATE_DEVICE_LOCKED = 'device_locked' +ATTR_STATE_LOCKED = 'locked' +ATTR_STATE_BATTERY_LOW = 'battery_low' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICES): + vol.All(cv.ensure_list, [ + vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + }), + ]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the fritzbox component.""" + from pyfritzhome import Fritzhome, LoginError + + fritz_list = [] + + configured_devices = config[DOMAIN].get(CONF_DEVICES) + for device in configured_devices: + host = device.get(CONF_HOST) + username = device.get(CONF_USERNAME) + password = device.get(CONF_PASSWORD) + fritzbox = Fritzhome(host=host, user=username, + password=password) + try: + fritzbox.login() + _LOGGER.info("Connected to device %s", device) + except LoginError: + _LOGGER.warning("Login to Fritz!Box %s as %s failed", + host, username) + continue + + fritz_list.append(fritzbox) + + if not fritz_list: + _LOGGER.info("No fritzboxes configured") + return False + + hass.data[DOMAIN] = fritz_list + + def logout_fritzboxes(event): + """Close all connections to the fritzboxes.""" + for fritz in fritz_list: + fritz.logout() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes) + + for domain in SUPPORTED_DOMAINS: + discovery.load_platform(hass, domain, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3fc3eff0a14..87ca8bd2a28 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20180404.0'] +REQUIREMENTS = ['home-assistant-frontend==20180420.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index 30151ee1a56..b41d4ea33a2 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -44,6 +44,7 @@ CONF_ENTITIES = 'entities' CONF_TRACK = 'track' CONF_SEARCH = 'search' CONF_OFFSET = 'offset' +CONF_IGNORE_AVAILABILITY = 'ignore_availability' DEFAULT_CONF_TRACK_NEW = True DEFAULT_CONF_OFFSET = '!!' @@ -74,8 +75,9 @@ _SINGLE_CALSEARCH_CONFIG = vol.Schema({ vol.Required(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string, vol.Optional(CONF_TRACK): cv.boolean, - vol.Optional(CONF_SEARCH): vol.Any(cv.string, None), + vol.Optional(CONF_SEARCH): cv.string, vol.Optional(CONF_OFFSET): cv.string, + vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean, }) DEVICE_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index 8e2464d0922..b5d64f48dc7 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -35,7 +35,7 @@ CONF_TYPES = 'types' ICON_UNKNOWN = 'mdi:help' ICON_AUDIO = 'mdi:speaker' ICON_PLAYER = 'mdi:play' -ICON_TUNER = 'mdi:nest-thermostat' +ICON_TUNER = 'mdi:radio' ICON_RECORDER = 'mdi:microphone' ICON_TV = 'mdi:television' ICONS_BY_TYPE = { diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 8ab91b08a3d..b5ac37b1451 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -118,6 +118,30 @@ def state_changes_during_period(hass, start_time, end_time=None, return states_to_json(hass, states, start_time, entity_ids) +def get_last_state_changes(hass, number_of_states, entity_id): + """Return the last number_of_states.""" + from homeassistant.components.recorder.models import States + + start_time = dt_util.utcnow() + + with session_scope(hass=hass) as session: + query = session.query(States).filter( + (States.last_changed == States.last_updated)) + + if entity_id is not None: + query = query.filter_by(entity_id=entity_id.lower()) + + entity_ids = [entity_id] if entity_id is not None else None + + states = execute( + query.order_by(States.last_updated.desc()).limit(number_of_states)) + + return states_to_json(hass, reversed(states), + start_time, + entity_ids, + include_start_time_state=False) + + def get_states(hass, utc_point_in_time, entity_ids=None, run=None, filters=None): """Return the states at a specific point in time.""" diff --git a/homeassistant/components/hive.py b/homeassistant/components/hive.py index abe52ebe98a..aa662fc2fb6 100644 --- a/homeassistant/components/hive.py +++ b/homeassistant/components/hive.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL, import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform -REQUIREMENTS = ['pyhiveapi==0.2.11'] +REQUIREMENTS = ['pyhiveapi==0.2.14'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'hive' @@ -44,6 +44,8 @@ class HiveSession: light = None sensor = None switch = None + weather = None + attributes = None def setup(hass, config): @@ -70,6 +72,8 @@ def setup(hass, config): session.hotwater = Pyhiveapi.Hotwater() session.light = Pyhiveapi.Light() session.switch = Pyhiveapi.Switch() + session.weather = Pyhiveapi.Weather() + session.attributes = Pyhiveapi.Attributes() hass.data[DATA_HIVE] = session for ha_type, hive_type in DEVICETYPES.items(): diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 948e26be291..24c6dfa8a76 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -8,12 +8,11 @@ from zlib import adler32 import voluptuous as vol -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) -from homeassistant.components.cover import SUPPORT_SET_POSITION +from homeassistant.components.cover import ( + SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) from homeassistant.const import ( - ATTR_CODE, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + ATTR_DEVICE_CLASS, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA @@ -21,14 +20,16 @@ from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry from .const import ( DOMAIN, HOMEKIT_FILE, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FILTER, - DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START) + DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START, + DEVICE_CLASS_CO2, DEVICE_CLASS_LIGHT, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PM25, DEVICE_CLASS_TEMPERATURE) from .util import ( validate_entity_config, show_setup_message) TYPES = Registry() _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['HAP-python==1.1.8'] +REQUIREMENTS = ['HAP-python==1.1.9'] CONFIG_SCHEMA = vol.Schema({ @@ -79,55 +80,64 @@ def get_accessory(hass, state, aid, config): state.entity_id) return None - if state.domain == 'sensor': - 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, - state.name, aid=aid) - elif unit == '%': - _LOGGER.debug('Add "%s" as %s"', - state.entity_id, 'HumiditySensor') - return TYPES['HumiditySensor'](hass, state.entity_id, state.name, - aid=aid) + a_type = None + config = config or {} - elif state.domain == 'cover': - # Only add covers that support set_cover_position - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if features & SUPPORT_SET_POSITION: - _LOGGER.debug('Add "%s" as "%s"', - state.entity_id, 'WindowCovering') - return TYPES['WindowCovering'](hass, state.entity_id, state.name, - aid=aid) + if state.domain == 'alarm_control_panel': + a_type = 'SecuritySystem' - 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, - alarm_code=config.get(ATTR_CODE), - aid=aid) + elif state.domain == 'binary_sensor' or state.domain == 'device_tracker': + a_type = 'BinarySensor' elif state.domain == 'climate': - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - support_temp_range = SUPPORT_TARGET_TEMPERATURE_LOW | \ - SUPPORT_TARGET_TEMPERATURE_HIGH - # Check if climate device supports auto mode - support_auto = bool(features & support_temp_range) + a_type = 'Thermostat' - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Thermostat') - return TYPES['Thermostat'](hass, state.entity_id, - state.name, support_auto, aid=aid) + elif state.domain == 'cover': + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == 'garage' and \ + features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'GarageDoorOpener' + elif features & SUPPORT_SET_POSITION: + a_type = 'WindowCovering' + elif features & (SUPPORT_OPEN | SUPPORT_CLOSE): + a_type = 'WindowCoveringBasic' elif state.domain == 'light': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Light') - return TYPES['Light'](hass, state.entity_id, state.name, aid=aid) + a_type = 'Light' + + elif state.domain == 'lock': + a_type = 'Lock' + + elif state.domain == 'sensor': + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + + if device_class == DEVICE_CLASS_TEMPERATURE or unit == TEMP_CELSIUS \ + or unit == TEMP_FAHRENHEIT: + a_type = 'TemperatureSensor' + elif device_class == DEVICE_CLASS_HUMIDITY or unit == '%': + a_type = 'HumiditySensor' + elif device_class == DEVICE_CLASS_PM25 \ + or DEVICE_CLASS_PM25 in state.entity_id: + a_type = 'AirQualitySensor' + elif device_class == DEVICE_CLASS_CO2 \ + or DEVICE_CLASS_CO2 in state.entity_id: + a_type = 'CarbonDioxideSensor' + elif device_class == DEVICE_CLASS_LIGHT or unit == 'lm' or \ + unit == 'lux': + a_type = 'LightSensor' elif state.domain == 'switch' or state.domain == 'remote' \ or state.domain == 'input_boolean' or state.domain == 'script': - _LOGGER.debug('Add "%s" as "%s"', state.entity_id, 'Switch') - return TYPES['Switch'](hass, state.entity_id, state.name, aid=aid) + a_type = 'Switch' - return None + if a_type is None: + return None + + _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) + return TYPES[a_type](hass, state.name, state.entity_id, aid, config=config) def generate_aid(entity_id): @@ -143,7 +153,7 @@ class HomeKit(): def __init__(self, hass, port, entity_filter, entity_config): """Initialize a HomeKit object.""" - self._hass = hass + self.hass = hass self._port = port self._filter = entity_filter self._config = entity_config @@ -156,11 +166,11 @@ class HomeKit(): """Setup bridge and accessory driver.""" from .accessories import HomeBridge, HomeDriver - self._hass.bus.async_listen_once( + self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, self.stop) - path = self._hass.config.path(HOMEKIT_FILE) - self.bridge = HomeBridge(self._hass) + path = self.hass.config.path(HOMEKIT_FILE) + self.bridge = HomeBridge(self.hass) self.driver = HomeDriver(self.bridge, self._port, get_local_ip(), path) def add_bridge_accessory(self, state): @@ -169,7 +179,7 @@ class HomeKit(): return aid = generate_aid(state.entity_id) conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self._hass, state, aid, conf) + acc = get_accessory(self.hass, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) @@ -181,15 +191,15 @@ class HomeKit(): # pylint: disable=unused-variable from . import ( # noqa F401 - type_covers, type_lights, type_security_systems, type_sensors, - type_switches, type_thermostats) + type_covers, type_lights, type_locks, type_security_systems, + type_sensors, type_switches, type_thermostats) - for state in self._hass.states.all(): + for state in self.hass.states.all(): self.add_bridge_accessory(state) self.bridge.set_broker(self.driver) if not self.bridge.paired: - show_setup_message(self.bridge, self._hass) + show_setup_message(self.hass, self.bridge) _LOGGER.debug('Driver start') self.driver.start() diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index da45bee9e90..d9b90a77d68 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,21 +1,64 @@ """Extend the basic Accessory and Bridge functions.""" +from datetime import timedelta +from functools import wraps +from inspect import getmodule import logging from pyhap.accessory import Accessory, Bridge, Category from pyhap.accessory_driver import AccessoryDriver -from homeassistant.helpers.event import async_track_state_change +from homeassistant.core import callback as ha_callback +from homeassistant.helpers.event import ( + async_track_state_change, track_point_in_utc_time) +from homeassistant.util import dt as dt_util from .const import ( - ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, BRIDGE_NAME, - MANUFACTURER, SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, - CHAR_NAME, CHAR_SERIAL_NUMBER) + DEBOUNCE_TIMEOUT, BRIDGE_MODEL, BRIDGE_NAME, MANUFACTURER, + SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, + CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) from .util import ( show_setup_message, dismiss_setup_message) _LOGGER = logging.getLogger(__name__) +def debounce(func): + """Decorator function. Debounce callbacks form HomeKit.""" + @ha_callback + def call_later_listener(*args): + """Callback listener called from call_later.""" + # pylint: disable=unsubscriptable-object + nonlocal lastargs, remove_listener + hass = lastargs['hass'] + hass.async_add_job(func, *lastargs['args']) + lastargs = remove_listener = None + + @wraps(func) + def wrapper(*args): + """Wrapper starts async timer. + + The accessory must have 'self.hass' and 'self.entity_id' as attributes. + """ + # pylint: disable=not-callable + hass = args[0].hass + nonlocal lastargs, remove_listener + if remove_listener: + remove_listener() + lastargs = remove_listener = None + lastargs = {'hass': hass, 'args': [*args]} + remove_listener = track_point_in_utc_time( + hass, call_later_listener, + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) + logger.debug('%s: Start %s timeout', args[0].entity_id, + func.__name__.replace('set_', '')) + + remove_listener = None + lastargs = None + name = getmodule(func).__name__ + logger = logging.getLogger(name) + return wrapper + + def add_preload_service(acc, service, chars=None): """Define and return a service to be available for the accessory.""" from pyhap.loader import get_serv_loader, get_char_loader @@ -29,6 +72,18 @@ def add_preload_service(acc, service, chars=None): return service +def setup_char(char_name, service, value=None, properties=None, callback=None): + """Helper function to return fully configured characteristic.""" + char = service.get_characteristic(char_name) + if value: + char.value = value + if properties: + char.override_properties(properties) + if callback: + char.setter_callback = callback + return char + + def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, serial_number='0000'): """Set the default accessory information.""" @@ -42,14 +97,13 @@ def set_accessory_info(acc, name, model, manufacturer=MANUFACTURER, class HomeAccessory(Accessory): """Adapter class for Accessory.""" - # pylint: disable=no-member - - def __init__(self, name=ACCESSORY_NAME, model=ACCESSORY_MODEL, - category='OTHER', **kwargs): + def __init__(self, hass, name, entity_id, aid, category): """Initialize a Accessory object.""" - super().__init__(name, **kwargs) - set_accessory_info(self, name, model) + super().__init__(name, aid=aid) + set_accessory_info(self, name, model=entity_id) self.category = getattr(Category, category, Category.OTHER) + self.entity_id = entity_id + self.hass = hass def _set_services(self): add_preload_service(self, SERV_ACCESSORY_INFO) @@ -57,19 +111,33 @@ class HomeAccessory(Accessory): def run(self): """Method called by accessory after driver is started.""" state = self.hass.states.get(self.entity_id) - self.update_state(new_state=state) + self.update_state_callback(new_state=state) async_track_state_change( - self.hass, self.entity_id, self.update_state) + self.hass, self.entity_id, self.update_state_callback) + + def update_state_callback(self, entity_id=None, old_state=None, + new_state=None): + """Callback from state change listener.""" + _LOGGER.debug('New_state: %s', new_state) + if new_state is None: + return + self.update_state(new_state) + + def update_state(self, new_state): + """Method called on state change to update HomeKit value. + + Overridden by accessory types. + """ + pass class HomeBridge(Bridge): """Adapter class for Bridge.""" - def __init__(self, hass, name=BRIDGE_NAME, - model=BRIDGE_MODEL, **kwargs): + def __init__(self, hass, name=BRIDGE_NAME): """Initialize a Bridge object.""" - super().__init__(name, **kwargs) - set_accessory_info(self, name, model) + super().__init__(name) + set_accessory_info(self, name, model=BRIDGE_MODEL) self.hass = hass def _set_services(self): @@ -87,7 +155,7 @@ class HomeBridge(Bridge): def remove_paired_client(self, client_uuid): """Override super function to show setup message if unpaired.""" super().remove_paired_client(client_uuid) - show_setup_message(self, self.hass) + show_setup_message(self.hass, self) class HomeDriver(AccessoryDriver): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d1c3d84b517..1c498b4b3b9 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,5 +1,6 @@ """Constants used be the HomeKit component.""" # #### MISC #### +DEBOUNCE_TIMEOUT = 0.5 DOMAIN = 'homekit' HOMEKIT_FILE = '.homekit.state' HOMEKIT_NOTIFY_ID = 4663548 @@ -17,15 +18,15 @@ DEFAULT_PORT = 51827 SERVICE_HOMEKIT_START = 'start' # #### STRING CONSTANTS #### -ACCESSORY_MODEL = 'homekit.accessory' -ACCESSORY_NAME = 'Home Accessory' BRIDGE_MODEL = 'homekit.bridge' BRIDGE_NAME = 'Home Assistant' MANUFACTURER = 'HomeAssistant' # #### Categories #### CATEGORY_ALARM_SYSTEM = 'ALARM_SYSTEM' +CATEGORY_GARAGE_DOOR_OPENER = 'GARAGE_DOOR_OPENER' CATEGORY_LIGHT = 'LIGHTBULB' +CATEGORY_LOCK = 'DOOR_LOCK' CATEGORY_SENSOR = 'SENSOR' CATEGORY_SWITCH = 'SWITCH' CATEGORY_THERMOSTAT = 'THERMOSTAT' @@ -34,40 +35,80 @@ CATEGORY_WINDOW_COVERING = 'WINDOW_COVERING' # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' -SERV_HUMIDITY_SENSOR = 'HumiditySensor' -# CurrentRelativeHumidity | StatusActive, StatusFault, StatusTampered, -# StatusLowBattery, Name +SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' +SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' +SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' +SERV_CONTACT_SENSOR = 'ContactSensor' +SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' +SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity +SERV_LEAK_SENSOR = 'LeakSensor' +SERV_LIGHT_SENSOR = 'LightSensor' SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name +SERV_LOCK = 'LockMechanism' +SERV_MOTION_SENSOR = 'MotionSensor' +SERV_OCCUPANCY_SENSOR = 'OccupancySensor' SERV_SECURITY_SYSTEM = 'SecuritySystem' +SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' SERV_WINDOW_COVERING = 'WindowCovering' +# CurrentPosition, TargetPosition, PositionState # #### Characteristics #### +CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' +CHAR_AIR_QUALITY = 'AirQuality' CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100] +CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' +CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' +CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' +CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' +CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' +CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' +CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' -CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' CHAR_HUE = 'Hue' # arcdegress | [0, 360] +CHAR_LEAK_DETECTED = 'LeakDetected' +CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' +CHAR_LOCK_TARGET_STATE = 'LockTargetState' +CHAR_LINK_QUALITY = 'LinkQuality' CHAR_MANUFACTURER = 'Manufacturer' CHAR_MODEL = 'Model' +CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' +CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' CHAR_ON = 'On' # boolean CHAR_POSITION_STATE = 'PositionState' CHAR_SATURATION = 'Saturation' # percent CHAR_SERIAL_NUMBER = 'SerialNumber' +CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' -CHAR_TARGET_POSITION = 'TargetPosition' +CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100] CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' CHAR_TARGET_TEMPERATURE = 'TargetTemperature' CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' # #### Properties #### PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} + +# #### Device Class #### +DEVICE_CLASS_CO2 = 'co2' +DEVICE_CLASS_GAS = 'gas' +DEVICE_CLASS_HUMIDITY = 'humidity' +DEVICE_CLASS_LIGHT = 'light' +DEVICE_CLASS_MOISTURE = 'moisture' +DEVICE_CLASS_MOTION = 'motion' +DEVICE_CLASS_OCCUPANCY = 'occupancy' +DEVICE_CLASS_OPENING = 'opening' +DEVICE_CLASS_PM25 = 'pm25' +DEVICE_CLASS_SMOKE = 'smoke' +DEVICE_CLASS_TEMPERATURE = 'temperature' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 3650a948f5d..8ec715e0e01 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,18 +1,67 @@ """Class to hold all cover accessories.""" import logging -from homeassistant.components.cover import ATTR_CURRENT_POSITION +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED, + SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_STOP_COVER, + ATTR_SUPPORTED_FEATURES) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_WINDOW_COVERING, SERV_WINDOW_COVERING, - CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE) - + CHAR_CURRENT_POSITION, CHAR_TARGET_POSITION, CHAR_POSITION_STATE, + CATEGORY_GARAGE_DOOR_OPENER, SERV_GARAGE_DOOR_OPENER, + CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) _LOGGER = logging.getLogger(__name__) +@TYPES.register('GarageDoorOpener') +class GarageDoorOpener(HomeAccessory): + """Generate a Garage Door Opener accessory for a cover entity. + + The cover entity must be in the 'garage' device class + and support no more than open, close, and stop. + """ + + def __init__(self, *args, config): + """Initialize a GarageDoorOpener accessory object.""" + super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) + self.flag_target_state = False + + serv_garage_door = add_preload_service(self, SERV_GARAGE_DOOR_OPENER) + self.char_current_state = setup_char( + CHAR_CURRENT_DOOR_STATE, serv_garage_door, value=0) + self.char_target_state = setup_char( + CHAR_TARGET_DOOR_STATE, serv_garage_door, value=0, + callback=self.set_state) + + def set_state(self, value): + """Change garage state if call came from HomeKit.""" + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self.flag_target_state = True + + if value == 0: + self.char_current_state.set_value(3) + self.hass.components.cover.open_cover(self.entity_id) + elif value == 1: + self.char_current_state.set_value(2) + self.hass.components.cover.close_cover(self.entity_id) + + def update_state(self, new_state): + """Update cover state after state changed.""" + hass_state = new_state.state + if hass_state in (STATE_OPEN, STATE_CLOSED): + current_state = 0 if hass_state == STATE_OPEN else 1 + self.char_current_state.set_value(current_state) + if not self.flag_target_state: + self.char_target_state.set_value(current_state) + self.flag_target_state = False + + @TYPES.register('WindowCovering') class WindowCovering(HomeAccessory): """Generate a Window accessory for a cover entity. @@ -20,54 +69,91 @@ class WindowCovering(HomeAccessory): The cover entity must support: set_cover_position. """ - def __init__(self, hass, entity_id, display_name, **kwargs): + def __init__(self, *args, config): """Initialize a WindowCovering accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_WINDOW_COVERING, **kwargs) - - self.hass = hass - self.entity_id = entity_id - - self.current_position = None + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) self.homekit_target = None serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) - self.char_current_position = serv_cover. \ - get_characteristic(CHAR_CURRENT_POSITION) - self.char_target_position = serv_cover. \ - get_characteristic(CHAR_TARGET_POSITION) - self.char_position_state = 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 + self.char_current_position = setup_char( + CHAR_CURRENT_POSITION, serv_cover, value=0) + self.char_target_position = setup_char( + CHAR_TARGET_POSITION, serv_cover, value=0, + callback=self.move_cover) def move_cover(self, value): """Move cover to value if call came from HomeKit.""" - self.char_target_position.set_value(value, should_callback=False) - if value != self.current_position: - _LOGGER.debug('%s: Set position to %d', self.entity_id, value) - self.homekit_target = value - if value > self.current_position: - self.char_position_state.set_value(1) - elif value < self.current_position: - self.char_position_state.set_value(0) - self.hass.components.cover.set_cover_position( - value, self.entity_id) + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + self.homekit_target = value - def update_state(self, entity_id=None, old_state=None, new_state=None): + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} + self.hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, params) + + def update_state(self, new_state): """Update cover position after state changed.""" - if new_state is None: - return - current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, int): - self.current_position = current_position - self.char_current_position.set_value(self.current_position) + self.char_current_position.set_value(current_position) if self.homekit_target is None or \ - abs(self.current_position - self.homekit_target) < 6: - self.char_target_position.set_value(self.current_position) - self.char_position_state.set_value(2) + abs(current_position - self.homekit_target) < 6: + self.char_target_position.set_value(current_position) self.homekit_target = None + + +@TYPES.register('WindowCoveringBasic') +class WindowCoveringBasic(HomeAccessory): + """Generate a Window accessory for a cover entity. + + The cover entity must support: open_cover, close_cover, + stop_cover (optional). + """ + + def __init__(self, *args, config): + """Initialize a WindowCovering accessory object.""" + super().__init__(*args, category=CATEGORY_WINDOW_COVERING) + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + self.supports_stop = features & SUPPORT_STOP + + serv_cover = add_preload_service(self, SERV_WINDOW_COVERING) + self.char_current_position = setup_char( + CHAR_CURRENT_POSITION, serv_cover, value=0) + self.char_target_position = setup_char( + CHAR_TARGET_POSITION, serv_cover, value=0, + callback=self.move_cover) + self.char_position_state = setup_char( + CHAR_POSITION_STATE, serv_cover, value=2) + + def move_cover(self, value): + """Move cover to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set position to %d', self.entity_id, value) + + if self.supports_stop: + if value > 70: + service, position = (SERVICE_OPEN_COVER, 100) + elif value < 30: + service, position = (SERVICE_CLOSE_COVER, 0) + else: + service, position = (SERVICE_STOP_COVER, 50) + else: + if value >= 50: + service, position = (SERVICE_OPEN_COVER, 100) + else: + service, position = (SERVICE_CLOSE_COVER, 0) + + self.hass.services.call(DOMAIN, service, + {ATTR_ENTITY_ID: self.entity_id}) + + # Snap the current/target position to the expected final position. + self.char_current_position.set_value(position) + self.char_target_position.set_value(position) + self.char_position_state.set_value(2) + + def update_state(self, new_state): + """Update cover position after state changed.""" + position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0} + hk_position = position_mapping.get(new_state.state) + if hk_position is not None: + self.char_current_position.set_value(hk_position) + self.char_target_position.set_value(hk_position) + self.char_position_state.set_value(2) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 018d3cd2e74..9a7bce76fba 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -7,7 +7,8 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import ( + HomeAccessory, add_preload_service, debounce, setup_char) from .const import ( CATEGORY_LIGHT, SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE, CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION) @@ -24,12 +25,9 @@ class Light(HomeAccessory): Currently supports: state, brightness, color temperature, rgb_color. """ - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a new Light accessory object.""" - super().__init__(name, entity_id, CATEGORY_LIGHT, **kwargs) - - self.hass = hass - self.entity_id = entity_id + super().__init__(*args, category=CATEGORY_LIGHT) self._flag = {CHAR_ON: False, CHAR_BRIGHTNESS: False, CHAR_HUE: False, CHAR_SATURATION: False, CHAR_COLOR_TEMPERATURE: False, RGB_COLOR: False} @@ -49,36 +47,29 @@ class Light(HomeAccessory): self._saturation = None serv_light = add_preload_service(self, SERV_LIGHTBULB, self.chars) - self.char_on = serv_light.get_characteristic(CHAR_ON) - self.char_on.setter_callback = self.set_state - self.char_on.value = self._state + self.char_on = setup_char( + CHAR_ON, serv_light, value=self._state, callback=self.set_state) if CHAR_BRIGHTNESS in self.chars: - self.char_brightness = serv_light \ - .get_characteristic(CHAR_BRIGHTNESS) - self.char_brightness.setter_callback = self.set_brightness - self.char_brightness.value = 0 + self.char_brightness = setup_char( + CHAR_BRIGHTNESS, serv_light, value=0, + callback=self.set_brightness) if CHAR_COLOR_TEMPERATURE in self.chars: - self.char_color_temperature = serv_light \ - .get_characteristic(CHAR_COLOR_TEMPERATURE) - self.char_color_temperature.setter_callback = \ - self.set_color_temperature min_mireds = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_MIREDS, 153) max_mireds = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MAX_MIREDS, 500) - self.char_color_temperature.override_properties({ - 'minValue': min_mireds, 'maxValue': max_mireds}) - self.char_color_temperature.value = min_mireds + self.char_color_temperature = setup_char( + CHAR_COLOR_TEMPERATURE, serv_light, value=min_mireds, + properties={'minValue': min_mireds, 'maxValue': max_mireds}, + callback=self.set_color_temperature) if CHAR_HUE in self.chars: - self.char_hue = serv_light.get_characteristic(CHAR_HUE) - self.char_hue.setter_callback = self.set_hue - self.char_hue.value = 0 + self.char_hue = setup_char( + CHAR_HUE, serv_light, value=0, callback=self.set_hue) if CHAR_SATURATION in self.chars: - self.char_saturation = serv_light \ - .get_characteristic(CHAR_SATURATION) - self.char_saturation.setter_callback = self.set_saturation - self.char_saturation.value = 75 + self.char_saturation = setup_char( + CHAR_SATURATION, serv_light, value=75, + callback=self.set_saturation) def set_state(self, value): """Set state if call came from HomeKit.""" @@ -87,18 +78,17 @@ class Light(HomeAccessory): _LOGGER.debug('%s: Set state to %d', self.entity_id, value) self._flag[CHAR_ON] = True - self.char_on.set_value(value, should_callback=False) if value == 1: self.hass.components.light.turn_on(self.entity_id) elif value == 0: self.hass.components.light.turn_off(self.entity_id) + @debounce def set_brightness(self, value): """Set brightness if call came from HomeKit.""" _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) self._flag[CHAR_BRIGHTNESS] = True - self.char_brightness.set_value(value, should_callback=False) if value != 0: self.hass.components.light.turn_on( self.entity_id, brightness_pct=value) @@ -109,14 +99,12 @@ class Light(HomeAccessory): """Set color temperature if call came from HomeKit.""" _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value) self._flag[CHAR_COLOR_TEMPERATURE] = True - self.char_color_temperature.set_value(value, should_callback=False) self.hass.components.light.turn_on(self.entity_id, color_temp=value) def set_saturation(self, value): """Set saturation if call came from HomeKit.""" _LOGGER.debug('%s: Set saturation to %d', self.entity_id, value) self._flag[CHAR_SATURATION] = True - self.char_saturation.set_value(value, should_callback=False) self._saturation = value self.set_color() @@ -124,7 +112,6 @@ class Light(HomeAccessory): """Set hue if call came from HomeKit.""" _LOGGER.debug('%s: Set hue to %d', self.entity_id, value) self._flag[CHAR_HUE] = True - self.char_hue.set_value(value, should_callback=False) self._hue = value self.set_color() @@ -140,17 +127,14 @@ class Light(HomeAccessory): self.hass.components.light.turn_on( self.entity_id, hs_color=color) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update light after state change.""" - if not new_state: - return - # Handle State state = new_state.state if state in (STATE_ON, STATE_OFF): self._state = 1 if state == STATE_ON else 0 if not self._flag[CHAR_ON] and self.char_on.value != self._state: - self.char_on.set_value(self._state, should_callback=False) + self.char_on.set_value(self._state) self._flag[CHAR_ON] = False # Handle Brightness @@ -159,17 +143,16 @@ class Light(HomeAccessory): if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int): brightness = round(brightness / 255 * 100, 0) if self.char_brightness.value != brightness: - self.char_brightness.set_value(brightness, - should_callback=False) + self.char_brightness.set_value(brightness) self._flag[CHAR_BRIGHTNESS] = False # Handle color temperature if CHAR_COLOR_TEMPERATURE in self.chars: color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP) if not self._flag[CHAR_COLOR_TEMPERATURE] \ - and isinstance(color_temperature, int): - self.char_color_temperature.set_value(color_temperature, - should_callback=False) + and isinstance(color_temperature, int) and \ + self.char_color_temperature.value != color_temperature: + self.char_color_temperature.set_value(color_temperature) self._flag[CHAR_COLOR_TEMPERATURE] = False # Handle Color @@ -180,8 +163,7 @@ class Light(HomeAccessory): hue != self._hue or saturation != self._saturation) and \ isinstance(hue, (int, float)) and \ isinstance(saturation, (int, float)): - self.char_hue.set_value(hue, should_callback=False) - self.char_saturation.set_value(saturation, - should_callback=False) + self.char_hue.set_value(hue) + self.char_saturation.set_value(saturation) self._hue, self._saturation = (hue, saturation) self._flag[RGB_COLOR] = False diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py new file mode 100644 index 00000000000..f34fc6c6a7f --- /dev/null +++ b/homeassistant/components/homekit/type_locks.py @@ -0,0 +1,67 @@ +"""Class to hold all lock accessories.""" +import logging + +from homeassistant.components.lock import ( + ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) + +from . import TYPES +from .accessories import HomeAccessory, add_preload_service, setup_char +from .const import ( + CATEGORY_LOCK, SERV_LOCK, CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE) + +_LOGGER = logging.getLogger(__name__) + +HASS_TO_HOMEKIT = {STATE_UNLOCKED: 0, + STATE_LOCKED: 1, + # value 2 is Jammed which hass doesn't have a state for + STATE_UNKNOWN: 3} +HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} +STATE_TO_SERVICE = {STATE_LOCKED: 'lock', + STATE_UNLOCKED: 'unlock'} + + +@TYPES.register('Lock') +class Lock(HomeAccessory): + """Generate a Lock accessory for a lock entity. + + The lock entity must support: unlock and lock. + """ + + def __init__(self, *args, config): + """Initialize a Lock accessory object.""" + super().__init__(*args, category=CATEGORY_LOCK) + self.flag_target_state = False + + serv_lock_mechanism = add_preload_service(self, SERV_LOCK) + self.char_current_state = setup_char( + CHAR_LOCK_CURRENT_STATE, serv_lock_mechanism, + value=HASS_TO_HOMEKIT[STATE_UNKNOWN]) + self.char_target_state = setup_char( + CHAR_LOCK_TARGET_STATE, serv_lock_mechanism, + value=HASS_TO_HOMEKIT[STATE_LOCKED], callback=self.set_state) + + def set_state(self, value): + """Set lock state to value if call came from HomeKit.""" + _LOGGER.debug("%s: Set state to %d", self.entity_id, value) + self.flag_target_state = True + + hass_value = HOMEKIT_TO_HASS.get(value) + service = STATE_TO_SERVICE[hass_value] + + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call('lock', service, params) + + def update_state(self, new_state): + """Update lock after state changed.""" + hass_state = new_state.state + if hass_state in HASS_TO_HOMEKIT: + current_lock_state = HASS_TO_HOMEKIT[hass_state] + self.char_current_state.set_value(current_lock_state) + _LOGGER.debug('%s: Updated current state to %s (%d)', + self.entity_id, hass_state, current_lock_state) + + # LockTargetState only supports locked and unlocked + if hass_state in (STATE_LOCKED, STATE_UNLOCKED): + if not self.flag_target_state: + self.char_target_state.set_value(current_lock_state) + self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index 2cce6653db3..6b8457a3aa5 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -7,7 +7,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_CODE) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_ALARM_SYSTEM, SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE) @@ -27,33 +27,24 @@ STATE_TO_SERVICE = {STATE_ALARM_DISARMED: 'alarm_disarm', class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" - def __init__(self, hass, entity_id, display_name, alarm_code, **kwargs): + def __init__(self, *args, config): """Initialize a SecuritySystem accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_ALARM_SYSTEM, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._alarm_code = alarm_code - + super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) + self._alarm_code = config[ATTR_CODE] self.flag_target_state = False serv_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM) - self.char_current_state = serv_alarm. \ - get_characteristic(CHAR_CURRENT_SECURITY_STATE) - self.char_current_state.value = 3 - self.char_target_state = serv_alarm. \ - get_characteristic(CHAR_TARGET_SECURITY_STATE) - self.char_target_state.value = 3 - - self.char_target_state.setter_callback = self.set_security_state + self.char_current_state = setup_char( + CHAR_CURRENT_SECURITY_STATE, serv_alarm, value=3) + self.char_target_state = setup_char( + CHAR_TARGET_SECURITY_STATE, serv_alarm, value=3, + callback=self.set_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 - self.char_target_state.set_value(value, should_callback=False) hass_value = HOMEKIT_TO_HASS[value] service = STATE_TO_SERVICE[hass_value] @@ -62,23 +53,16 @@ class SecuritySystem(HomeAccessory): params[ATTR_CODE] = self._alarm_code self.hass.services.call('alarm_control_panel', service, params) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """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 + if hass_state in HASS_TO_HOMEKIT: + 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) - current_security_state = HASS_TO_HOMEKIT[hass_state] - self.char_current_state.set_value(current_security_state, - should_callback=False) - _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) - if self.char_target_state.value == self.char_current_state.value: - self.flag_target_state = False + if not self.flag_target_state: + self.char_target_state.set_value(current_security_state) + if self.char_target_state.value == self.char_current_state.value: + self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 80521df5991..6aa8d92c0af 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -2,18 +2,41 @@ import logging from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) + ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, + ATTR_DEVICE_CLASS, STATE_ON, STATE_HOME) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import ( CATEGORY_SENSOR, SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, - CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS) -from .util import convert_to_float, temperature_to_homekit - + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, + SERV_AIR_QUALITY_SENSOR, CHAR_AIR_QUALITY, CHAR_AIR_PARTICULATE_DENSITY, + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL, + SERV_LIGHT_SENSOR, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, + DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, + DEVICE_CLASS_GAS, SERV_CARBON_MONOXIDE_SENSOR, + CHAR_CARBON_MONOXIDE_DETECTED, + DEVICE_CLASS_MOISTURE, SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, + DEVICE_CLASS_MOTION, SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, + DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, + DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, + DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) +from .util import ( + convert_to_float, temperature_to_homekit, density_to_air_quality) _LOGGER = logging.getLogger(__name__) +BINARY_SENSOR_SERVICE_MAP = { + DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR, + CHAR_CARBON_DIOXIDE_DETECTED), + DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR, + CHAR_CARBON_MONOXIDE_DETECTED), + DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED), + DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED), + DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED), + DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE), + DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED)} + @TYPES.register('TemperatureSensor') class TemperatureSensor(HomeAccessory): @@ -22,29 +45,22 @@ class TemperatureSensor(HomeAccessory): Sensor entity must return temperature in °C, °F. """ - def __init__(self, hass, entity_id, name, **kwargs): + def __init__(self, *args, config): """Initialize a TemperatureSensor accessory object.""" - super().__init__(name, entity_id, CATEGORY_SENSOR, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_SENSOR) serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR) - self.char_temp = serv_temp.get_characteristic(CHAR_CURRENT_TEMPERATURE) - self.char_temp.override_properties(properties=PROP_CELSIUS) - self.char_temp.value = 0 + self.char_temp = setup_char( + CHAR_CURRENT_TEMPERATURE, serv_temp, value=0, + properties=PROP_CELSIUS) self.unit = None - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update temperature after state changed.""" - if new_state is None: - return - unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) temperature = convert_to_float(new_state.state) if temperature: temperature = temperature_to_homekit(temperature, unit) - self.char_temp.set_value(temperature, should_callback=False) + self.char_temp.set_value(temperature) _LOGGER.debug('%s: Current temperature set to %d°C', self.entity_id, temperature) @@ -53,25 +69,113 @@ class TemperatureSensor(HomeAccessory): class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" - def __init__(self, hass, entity_id, name, *args, **kwargs): + def __init__(self, *args, config): """Initialize a HumiditySensor accessory object.""" - super().__init__(name, entity_id, CATEGORY_SENSOR, *args, **kwargs) - - self.hass = hass - self.entity_id = entity_id - + super().__init__(*args, category=CATEGORY_SENSOR) serv_humidity = add_preload_service(self, SERV_HUMIDITY_SENSOR) - self.char_humidity = serv_humidity \ - .get_characteristic(CHAR_CURRENT_HUMIDITY) - self.char_humidity.value = 0 + self.char_humidity = setup_char( + CHAR_CURRENT_HUMIDITY, serv_humidity, value=0) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update accessory after state change.""" - if new_state is None: - return - humidity = convert_to_float(new_state.state) if humidity: - self.char_humidity.set_value(humidity, should_callback=False) + self.char_humidity.set_value(humidity) _LOGGER.debug('%s: Percent set to %d%%', self.entity_id, humidity) + + +@TYPES.register('AirQualitySensor') +class AirQualitySensor(HomeAccessory): + """Generate a AirQualitySensor accessory as air quality sensor.""" + + def __init__(self, *args, config): + """Initialize a AirQualitySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_air_quality = add_preload_service(self, SERV_AIR_QUALITY_SENSOR, + [CHAR_AIR_PARTICULATE_DENSITY]) + self.char_quality = setup_char( + CHAR_AIR_QUALITY, serv_air_quality, value=0) + self.char_density = setup_char( + CHAR_AIR_PARTICULATE_DENSITY, serv_air_quality, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + density = convert_to_float(new_state.state) + if density is not None: + self.char_density.set_value(density) + self.char_quality.set_value(density_to_air_quality(density)) + _LOGGER.debug('%s: Set to %d', self.entity_id, density) + + +@TYPES.register('CarbonDioxideSensor') +class CarbonDioxideSensor(HomeAccessory): + """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" + + def __init__(self, *args, config): + """Initialize a CarbonDioxideSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_co2 = add_preload_service(self, SERV_CARBON_DIOXIDE_SENSOR, [ + CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL]) + self.char_co2 = setup_char( + CHAR_CARBON_DIOXIDE_LEVEL, serv_co2, value=0) + self.char_peak = setup_char( + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, serv_co2, value=0) + self.char_detected = setup_char( + CHAR_CARBON_DIOXIDE_DETECTED, serv_co2, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + co2 = convert_to_float(new_state.state) + if co2 is not None: + self.char_co2.set_value(co2) + if co2 > self.char_peak.value: + self.char_peak.set_value(co2) + self.char_detected.set_value(co2 > 1000) + _LOGGER.debug('%s: Set to %d', self.entity_id, co2) + + +@TYPES.register('LightSensor') +class LightSensor(HomeAccessory): + """Generate a LightSensor accessory as light sensor.""" + + def __init__(self, *args, config): + """Initialize a LightSensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + + serv_light = add_preload_service(self, SERV_LIGHT_SENSOR) + self.char_light = setup_char( + CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, serv_light, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + luminance = convert_to_float(new_state.state) + if luminance is not None: + self.char_light.set_value(luminance) + _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) + + +@TYPES.register('BinarySensor') +class BinarySensor(HomeAccessory): + """Generate a BinarySensor accessory as binary sensor.""" + + def __init__(self, *args, config): + """Initialize a BinarySensor accessory object.""" + super().__init__(*args, category=CATEGORY_SENSOR) + device_class = self.hass.states.get(self.entity_id).attributes \ + .get(ATTR_DEVICE_CLASS) + service_char = BINARY_SENSOR_SERVICE_MAP[device_class] \ + if device_class in BINARY_SENSOR_SERVICE_MAP \ + else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY] + + service = add_preload_service(self, service_char[0]) + self.char_detected = setup_char(service_char[1], service, value=0) + + def update_state(self, new_state): + """Update accessory after state change.""" + state = new_state.state + detected = (state == STATE_ON) or (state == STATE_HOME) + self.char_detected.set_value(detected) + _LOGGER.debug('%s: Set to %d', self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 689edde6f37..aaf13e4ea7e 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -6,7 +6,7 @@ from homeassistant.const import ( from homeassistant.core import split_entity_id from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import HomeAccessory, add_preload_service, setup_char from .const import CATEGORY_SWITCH, SERV_SWITCH, CHAR_ON _LOGGER = logging.getLogger(__name__) @@ -16,40 +16,30 @@ _LOGGER = logging.getLogger(__name__) class Switch(HomeAccessory): """Generate a Switch accessory.""" - def __init__(self, hass, entity_id, display_name, **kwargs): + def __init__(self, *args, config): """Initialize a Switch accessory object to represent a remote.""" - super().__init__(display_name, entity_id, CATEGORY_SWITCH, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._domain = split_entity_id(entity_id)[0] - + super().__init__(*args, category=CATEGORY_SWITCH) + self._domain = split_entity_id(self.entity_id)[0] self.flag_target_state = False serv_switch = add_preload_service(self, SERV_SWITCH) - self.char_on = serv_switch.get_characteristic(CHAR_ON) - self.char_on.value = False - self.char_on.setter_callback = self.set_state + self.char_on = setup_char( + CHAR_ON, serv_switch, value=False, callback=self.set_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 - self.char_on.set_value(value, should_callback=False) service = SERVICE_TURN_ON if value else SERVICE_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): + def update_state(self, new_state): """Update switch state after state changed.""" - if new_state is None: - return - current_state = (new_state.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) - + self.char_on.set_value(current_state) self.flag_target_state = False diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 69b61062791..ce10b96c51c 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -5,12 +5,15 @@ 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) + STATE_HEAT, STATE_COOL, STATE_AUTO, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import TYPES -from .accessories import HomeAccessory, add_preload_service +from .accessories import ( + HomeAccessory, add_preload_service, debounce, setup_char) from .const import ( CATEGORY_THERMOSTAT, SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, @@ -26,78 +29,66 @@ 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()} +SUPPORT_TEMP_RANGE = SUPPORT_TARGET_TEMPERATURE_LOW | \ + SUPPORT_TARGET_TEMPERATURE_HIGH + @TYPES.register('Thermostat') class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" - def __init__(self, hass, entity_id, display_name, support_auto, **kwargs): + def __init__(self, *args, config): """Initialize a Thermostat accessory object.""" - super().__init__(display_name, entity_id, - CATEGORY_THERMOSTAT, **kwargs) - - self.hass = hass - self.entity_id = entity_id - self._call_timer = None + super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = TEMP_CELSIUS - 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 # Add additional characteristics if auto mode is supported - extra_chars = [ - CHAR_COOLING_THRESHOLD_TEMPERATURE, - CHAR_HEATING_THRESHOLD_TEMPERATURE] if support_auto else None + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + if features & SUPPORT_TEMP_RANGE: + self.chars.extend((CHAR_COOLING_THRESHOLD_TEMPERATURE, + CHAR_HEATING_THRESHOLD_TEMPERATURE)) - # Preload the thermostat service - serv_thermostat = add_preload_service(self, SERV_THERMOSTAT, - extra_chars) + serv_thermostat = add_preload_service( + self, SERV_THERMOSTAT, self.chars) # Current and target mode characteristics - self.char_current_heat_cool = serv_thermostat. \ - get_characteristic(CHAR_CURRENT_HEATING_COOLING) - self.char_current_heat_cool.value = 0 - self.char_target_heat_cool = serv_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 + self.char_current_heat_cool = setup_char( + CHAR_CURRENT_HEATING_COOLING, serv_thermostat, value=0) + self.char_target_heat_cool = setup_char( + CHAR_TARGET_HEATING_COOLING, serv_thermostat, value=0, + callback=self.set_heat_cool) # Current and target temperature characteristics - self.char_current_temp = serv_thermostat. \ - get_characteristic(CHAR_CURRENT_TEMPERATURE) - self.char_current_temp.value = 21.0 - self.char_target_temp = serv_thermostat. \ - get_characteristic(CHAR_TARGET_TEMPERATURE) - self.char_target_temp.value = 21.0 - self.char_target_temp.setter_callback = self.set_target_temperature + self.char_current_temp = setup_char( + CHAR_CURRENT_TEMPERATURE, serv_thermostat, value=21.0) + self.char_target_temp = setup_char( + CHAR_TARGET_TEMPERATURE, serv_thermostat, value=21.0, + callback=self.set_target_temperature) # Display units characteristic - self.char_display_units = serv_thermostat. \ - get_characteristic(CHAR_TEMP_DISPLAY_UNITS) - self.char_display_units.value = 0 + self.char_display_units = setup_char( + CHAR_TEMP_DISPLAY_UNITS, serv_thermostat, value=0) # If the device supports it: high and low temperature characteristics - if support_auto: - self.char_cooling_thresh_temp = serv_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 = serv_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 + self.char_cooling_thresh_temp = None + self.char_heating_thresh_temp = None + if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars: + self.char_cooling_thresh_temp = setup_char( + CHAR_COOLING_THRESHOLD_TEMPERATURE, serv_thermostat, + value=23.0, callback=self.set_cooling_threshold) + if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: + self.char_heating_thresh_temp = setup_char( + CHAR_HEATING_THRESHOLD_TEMPERATURE, serv_thermostat, + value=19.0, callback=self.set_heating_threshold) def set_heat_cool(self, value): """Move operation mode to value if call came from HomeKit.""" - self.char_target_heat_cool.set_value(value, should_callback=False) 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 @@ -105,12 +96,12 @@ class Thermostat(HomeAccessory): self.hass.components.climate.set_operation_mode( operation_mode=hass_value, entity_id=self.entity_id) + @debounce 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°C', self.entity_id, value) self.coolingthresh_flag_target_state = True - self.char_cooling_thresh_temp.set_value(value, should_callback=False) low = self.char_heating_thresh_temp.value low = temperature_to_states(low, self._unit) value = temperature_to_states(value, self._unit) @@ -118,12 +109,12 @@ class Thermostat(HomeAccessory): entity_id=self.entity_id, target_temp_high=value, target_temp_low=low) + @debounce 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°C', self.entity_id, value) self.heatingthresh_flag_target_state = True - self.char_heating_thresh_temp.set_value(value, should_callback=False) # Home assistant always wants to set low and high at the same time high = self.char_cooling_thresh_temp.value high = temperature_to_states(high, self._unit) @@ -132,21 +123,18 @@ class Thermostat(HomeAccessory): entity_id=self.entity_id, target_temp_high=high, target_temp_low=value) + @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug('%s: Set target temperature to %.2f°C', self.entity_id, value) self.temperature_flag_target_state = True - self.char_target_temp.set_value(value, should_callback=False) value = temperature_to_states(value, self._unit) self.hass.components.climate.set_temperature( temperature=value, entity_id=self.entity_id) - def update_state(self, entity_id=None, old_state=None, new_state=None): + def update_state(self, new_state): """Update security state after state changed.""" - if new_state is None: - return - self._unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) @@ -161,8 +149,7 @@ class Thermostat(HomeAccessory): if isinstance(target_temp, (int, float)): target_temp = temperature_to_homekit(target_temp, self._unit) if not self.temperature_flag_target_state: - self.char_target_temp.set_value(target_temp, - should_callback=False) + self.char_target_temp.set_value(target_temp) self.temperature_flag_target_state = False # Update cooling threshold temperature if characteristic exists @@ -172,8 +159,7 @@ class Thermostat(HomeAccessory): cooling_thresh = temperature_to_homekit(cooling_thresh, self._unit) if not self.coolingthresh_flag_target_state: - self.char_cooling_thresh_temp.set_value( - cooling_thresh, should_callback=False) + self.char_cooling_thresh_temp.set_value(cooling_thresh) self.coolingthresh_flag_target_state = False # Update heating threshold temperature if characteristic exists @@ -183,8 +169,7 @@ class Thermostat(HomeAccessory): heating_thresh = temperature_to_homekit(heating_thresh, self._unit) if not self.heatingthresh_flag_target_state: - self.char_heating_thresh_temp.set_value( - heating_thresh, should_callback=False) + self.char_heating_thresh_temp.set_value(heating_thresh) self.heatingthresh_flag_target_state = False # Update display units @@ -197,7 +182,7 @@ class Thermostat(HomeAccessory): 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) + HC_HASS_TO_HOMEKIT[operation_mode]) self.heat_cool_flag_target_state = False # Set current operation mode based on temperatures and target mode diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index af2c74d9c3c..29fe3c8f265 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -33,7 +33,7 @@ def validate_entity_config(values): return entities -def show_setup_message(bridge, hass): +def show_setup_message(hass, bridge): """Display persistent notification with setup information.""" pin = bridge.pincode.decode() _LOGGER.info('Pincode: %s', pin) @@ -64,3 +64,16 @@ def temperature_to_homekit(temperature, unit): def temperature_to_states(temperature, unit): """Convert temperature back from Celsius to Home Assistant unit.""" return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1) + + +def density_to_air_quality(density): + """Map PM2.5 density to HomeKit AirQuality level.""" + if density <= 35: + return 1 + elif density <= 75: + return 2 + elif density <= 115: + return 3 + elif density <= 150: + return 4 + return 5 diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py new file mode 100644 index 00000000000..c33edd07918 --- /dev/null +++ b/homeassistant/components/homekit_controller/__init__.py @@ -0,0 +1,228 @@ +""" +Support for Homekit device discovery. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homekit_controller/ +""" +import http +import json +import logging +import os +import uuid + +from homeassistant.components.discovery import SERVICE_HOMEKIT +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['homekit==0.5'] + +DOMAIN = 'homekit_controller' +HOMEKIT_DIR = '.homekit' + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', +} + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +def homekit_http_send(self, message_body=None): + r"""Send the currently buffered request and clear the buffer. + + Appends an extra \r\n to the buffer. + A message_body may be specified, to be appended to the request. + """ + self._buffer.extend((b"", b"")) + msg = b"\r\n".join(self._buffer) + del self._buffer[:] + + if message_body is not None: + msg = msg + message_body + + self.send(msg) + + +def get_serial(accessory): + """Obtain the serial number of a HomeKit device.""" + # pylint: disable=import-error + import homekit + for service in accessory['services']: + if homekit.ServicesTypes.get_short(service['type']) != \ + 'accessory-information': + continue + for characteristic in service['characteristics']: + ctype = homekit.CharacteristicsTypes.get_short( + characteristic['type']) + if ctype != 'serial-number': + continue + return characteristic['value'] + return None + + +class HKDevice(): + """HomeKit device.""" + + def __init__(self, hass, host, port, model, hkid, config_num, config): + """Initialise a generic HomeKit device.""" + # pylint: disable=import-error + import homekit + + _LOGGER.info("Setting up Homekit device %s", model) + self.hass = hass + self.host = host + self.port = port + self.model = model + self.hkid = hkid + self.config_num = config_num + self.config = config + self.configurator = hass.components.configurator + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + if not os.path.isdir(data_dir): + os.mkdir(data_dir) + + self.pairing_file = os.path.join(data_dir, 'hk-{}'.format(hkid)) + self.pairing_data = homekit.load_pairing(self.pairing_file) + + # Monkey patch httpclient for increased compatibility + # pylint: disable=protected-access + http.client.HTTPConnection._send_output = homekit_http_send + + self.conn = http.client.HTTPConnection(self.host, port=self.port) + if self.pairing_data is not None: + self.accessory_setup() + else: + self.configure() + + def accessory_setup(self): + """Handle setup of a HomeKit accessory.""" + # pylint: disable=import-error + import homekit + self.controllerkey, self.accessorykey = \ + homekit.get_session_keys(self.conn, self.pairing_data) + self.securecon = homekit.SecureHttp(self.conn.sock, + self.accessorykey, + self.controllerkey) + response = self.securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + serial = get_serial(accessory) + if serial in self.hass.data[KNOWN_ACCESSORIES]: + continue + self.hass.data[KNOWN_ACCESSORIES][serial] = self + aid = accessory['aid'] + for service in accessory['services']: + service_info = {'serial': serial, + 'aid': aid, + 'iid': service['iid']} + devtype = homekit.ServicesTypes.get_short(service['type']) + _LOGGER.debug("Found %s", devtype) + component = HOMEKIT_ACCESSORY_DISPATCH.get(devtype, None) + if component is not None: + discovery.load_platform(self.hass, component, DOMAIN, + service_info, self.config) + + def device_config_callback(self, callback_data): + """Handle initial pairing.""" + # pylint: disable=import-error + import homekit + pairing_id = str(uuid.uuid4()) + code = callback_data.get('code').strip() + self.pairing_data = homekit.perform_pair_setup( + self.conn, code, pairing_id) + if self.pairing_data is not None: + homekit.save_pairing(self.pairing_file, self.pairing_data) + self.accessory_setup() + else: + error_msg = "Unable to pair, please try again" + _configurator = self.hass.data[DOMAIN+self.hkid] + self.configurator.notify_errors(_configurator, error_msg) + + def configure(self): + """Obtain the pairing code for a HomeKit device.""" + description = "Please enter the HomeKit code for your {}".format( + self.model) + self.hass.data[DOMAIN+self.hkid] = \ + self.configurator.request_config(self.model, + self.device_config_callback, + description=description, + submit_caption="submit", + fields=[{'id': 'code', + 'name': 'HomeKit code', + 'type': 'string'}]) + + +class HomeKitEntity(Entity): + """Representation of a Home Assistant HomeKit device.""" + + def __init__(self, accessory, devinfo): + """Initialise a generic HomeKit device.""" + self._name = accessory.model + self._securecon = accessory.securecon + self._aid = devinfo['aid'] + self._iid = devinfo['iid'] + self._address = "homekit-{}-{}".format(devinfo['serial'], self._iid) + self._features = 0 + self._chars = {} + + def update(self): + """Obtain a HomeKit device's state.""" + response = self._securecon.get('/accessories') + data = json.loads(response.read().decode()) + for accessory in data['accessories']: + if accessory['aid'] != self._aid: + continue + for service in accessory['services']: + if service['iid'] != self._iid: + continue + self.update_characteristics(service['characteristics']) + break + + @property + def unique_id(self): + """Return the ID of this device.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + def update_characteristics(self, characteristics): + """Synchronise a HomeKit device state with Home Assistant.""" + raise NotImplementedError + + +# pylint: too-many-function-args +def setup(hass, config): + """Set up for Homekit devices.""" + def discovery_dispatch(service, discovery_info): + """Dispatcher for Homekit discovery events.""" + # model, id + host = discovery_info['host'] + port = discovery_info['port'] + model = discovery_info['properties']['md'] + hkid = discovery_info['properties']['id'] + config_num = int(discovery_info['properties']['c#']) + + # Only register a device once, but rescan if the config has changed + if hkid in hass.data[KNOWN_DEVICES]: + device = hass.data[KNOWN_DEVICES][hkid] + if config_num > device.config_num and \ + device.pairing_info is not None: + device.accessory_setup() + return + + _LOGGER.debug('Discovered unique device %s', hkid) + device = HKDevice(hass, host, port, model, hkid, config_num, config) + hass.data[KNOWN_DEVICES][hkid] = device + + hass.data[KNOWN_ACCESSORIES] = {} + hass.data[KNOWN_DEVICES] = {} + discovery.listen(hass, SERVICE_HOMEKIT, discovery_dispatch) + return True diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index c542cd9e88e..1528943a7f9 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.40'] +REQUIREMENTS = ['pyhomematic==0.1.41'] DOMAIN = 'homematic' _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,8 @@ HM_DEVICE_TYPES = { 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor', 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', - 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat'], + 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', + 'IPWeatherSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -78,7 +79,7 @@ HM_DEVICE_TYPES = { 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP'], + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -89,7 +90,7 @@ HM_IGNORE_DISCOVERY_NODE = [ ] HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { - 'ACTUAL_TEMPERATURE': ['IPAreaThermostat'], + 'ACTUAL_TEMPERATURE': ['IPAreaThermostat', 'IPWeatherSensor'], } HM_ATTRIBUTE_SUPPORT = { diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 557a47f3e05..0aed854d4e4 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -131,3 +131,9 @@ async def async_setup_entry(hass, entry): bridge = HueBridge(hass, entry, allow_unreachable, allow_groups) hass.data[DOMAIN][host] = bridge return await bridge.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + bridge = hass.data[DOMAIN].pop(entry.data['host']) + return await bridge.async_reset() diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 8093c84971e..5ff5e2dbf6f 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -30,6 +30,7 @@ class HueBridge(object): self.allow_groups = allow_groups self.available = True self.api = None + self._cancel_retry_setup = None @property def host(self): @@ -39,18 +40,17 @@ class HueBridge(object): async def async_setup(self, tries=0): """Set up a phue bridge based on host parameter.""" host = self.host + hass = self.hass try: self.api = await get_bridge( - self.hass, host, - self.config_entry.data['username'] - ) + hass, host, self.config_entry.data['username']) except AuthenticationRequired: # usernames can become invalid if hub is reset or user removed. # We are going to fail the config entry setup and initiate a new # linking procedure. When linking succeeds, it will remove the # old config entry. - self.hass.async_add_job(self.hass.config_entries.flow.async_init( + hass.async_add_job(hass.config_entries.flow.async_init( DOMAIN, source='import', data={ 'host': host, } @@ -68,8 +68,8 @@ class HueBridge(object): # This feels hacky, we should find a better way to do this self.config_entry.state = config_entries.ENTRY_STATE_LOADED - # Unhandled edge case: cancel this if we discover bridge on new IP - self.hass.helpers.event.async_call_later(retry_delay, retry_setup) + self._cancel_retry_setup = hass.helpers.event.async_call_later( + retry_delay, retry_setup) return False @@ -78,16 +78,43 @@ class HueBridge(object): host) return False - self.hass.async_add_job( - self.hass.helpers.discovery.async_load_platform( - 'light', DOMAIN, {'host': host})) + hass.async_add_job(hass.config_entries.async_forward_entry_setup( + self.config_entry, 'light')) - self.hass.services.async_register( + hass.services.async_register( DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene, schema=SCENE_SCHEMA) return True + async def async_reset(self): + """Reset this bridge to default state. + + Will cancel any scheduled setup retry and will unload + the config entry. + """ + # The bridge can be in 3 states: + # - Setup was successful, self.api is not None + # - Authentication was wrong, self.api is None, not retrying setup. + # - Host was down. self.api is None, we're retrying setup + + # If we have a retry scheduled, we were never setup. + if self._cancel_retry_setup is not None: + self._cancel_retry_setup() + self._cancel_retry_setup = None + return True + + # If the authentication was wrong. + if self.api is None: + return True + + self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE) + + # If setup was successful, we set api variable, forwarded entry and + # register service + return await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, 'light') + async def hue_activate_scene(self, call, updated=False): """Service to call directly into bridge to set scenes.""" group_name = call.data[ATTR_GROUP_NAME] diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 11e399c984d..af67a594495 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -6,7 +6,7 @@ import os import async_timeout import voluptuous as vol -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -41,7 +41,7 @@ def _find_username_from_config(hass, filename): @config_entries.HANDLERS.register(DOMAIN) -class HueFlowHandler(config_entries.ConfigFlowHandler): +class HueFlowHandler(data_entry_flow.FlowHandler): """Handle a Hue config flow.""" VERSION = 1 diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 031fa263e5a..0c0100bc9f5 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -1,4 +1,5 @@ -"""IHC component. +""" +Support for IHC devices. For more details about this component, please refer to the documentation at https://home-assistant.io/components/ihc/ @@ -6,18 +7,18 @@ https://home-assistant.io/components/ihc/ import logging import os.path import xml.etree.ElementTree + import voluptuous as vol from homeassistant.components.ihc.const import ( - ATTR_IHC_ID, ATTR_VALUE, CONF_INFO, CONF_AUTOSETUP, - CONF_BINARY_SENSOR, CONF_LIGHT, CONF_SENSOR, CONF_SWITCH, - CONF_XPATH, CONF_NODE, CONF_DIMMABLE, CONF_INVERTING, - SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_INT, - SERVICE_SET_RUNTIME_VALUE_FLOAT) + ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_SENSOR, CONF_SWITCH, + CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, + SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_ID, CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, CONF_TYPE, TEMP_CELSIUS) + CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, + CONF_URL, CONF_USERNAME, TEMP_CELSIUS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -36,7 +37,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, - vol.Optional(CONF_INFO, default=True): cv.boolean + vol.Optional(CONF_INFO, default=True): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) @@ -97,7 +98,7 @@ IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch') def setup(hass, config): - """Setup the IHC component.""" + """Set up the IHC component.""" from ihcsdk.ihccontroller import IHCController conf = config[DOMAIN] url = conf[CONF_URL] @@ -106,7 +107,7 @@ def setup(hass, config): ihc_controller = IHCController(url, username, password) if not ihc_controller.authenticate(): - _LOGGER.error("Unable to authenticate on ihc controller.") + _LOGGER.error("Unable to authenticate on IHC controller") return False if (conf[CONF_AUTOSETUP] and @@ -125,7 +126,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): """Auto setup of IHC products from the ihc project file.""" project_xml = ihc_controller.get_project() if not project_xml: - _LOGGER.error("Unable to read project from ihc controller.") + _LOGGER.error("Unable to read project from ICH controller") return False project = xml.etree.ElementTree.fromstring(project_xml) @@ -150,7 +151,7 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): def get_discovery_info(component_setup, groups): - """Get discovery info for specified component.""" + """Get discovery info for specified IHC component.""" discovery_data = {} for group in groups: groupname = group.attrib['name'] @@ -173,7 +174,7 @@ def get_discovery_info(component_setup, groups): def setup_service_functions(hass: HomeAssistantType, ihc_controller): - """Setup the ihc service functions.""" + """Setup the IHC service functions.""" def set_runtime_value_bool(call): """Set a IHC runtime bool value service function.""" ihc_id = call.data[ATTR_IHC_ID] diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index 59f4d95f0a1..de6db875def 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -1,4 +1,4 @@ -"""Implements a base class for all IHC devices.""" +"""Implementation of a base class for all IHC devices.""" import asyncio from xml.etree.ElementTree import Element @@ -6,7 +6,7 @@ from homeassistant.helpers.entity import Entity class IHCDevice(Entity): - """Base class for all ihc devices. + """Base class for all IHC devices. All IHC devices have an associated IHC resource. IHCDevice handled the registration of the IHC controller callback when the IHC resource changes. @@ -31,13 +31,13 @@ class IHCDevice(Entity): @asyncio.coroutine def async_added_to_hass(self): - """Add callback for ihc changes.""" + """Add callback for IHC changes.""" self.ihc_controller.add_notify_event( self.ihc_id, self.on_ihc_change, True) @property def should_poll(self) -> bool: - """No polling needed for ihc devices.""" + """No polling needed for IHC devices.""" return False @property @@ -58,7 +58,7 @@ class IHCDevice(Entity): } def on_ihc_change(self, ihc_id, value): - """Callback when ihc resource changes. + """Callback when IHC resource changes. Derived classes must overwrite this to do device specific stuff. """ diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 39d3203795e..30a1a800a44 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -334,7 +334,7 @@ class SetIntentHandler(intent.IntentHandler): async def async_setup(hass, config): """Expose light control via state machine and services.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS) await component.async_setup(config) @@ -388,6 +388,16 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class Profiles: """Representation of available color profiles.""" diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py index bfea19fc3fa..8b7e09d86bc 100644 --- a/homeassistant/components/light/abode.py +++ b/homeassistant/components/light/abode.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.abode/ """ import logging - +from math import ceil from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, @@ -51,7 +51,9 @@ class AbodeLight(AbodeDevice, Light): *kwargs[ATTR_HS_COLOR])) if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: - self._device.set_level(kwargs[ATTR_BRIGHTNESS]) + # Convert HASS brightness (0-255) to Abode brightness (0-99) + # If 100 is sent to Abode, response is 99 causing an error + self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0)) else: self._device.switch_on() @@ -68,7 +70,12 @@ class AbodeLight(AbodeDevice, Light): def brightness(self): """Return the brightness of the light.""" if self._device.is_dimmable and self._device.has_brightness: - return self._device.brightness + brightness = int(self._device.brightness) + # Abode returns 100 during device initialization and device refresh + if brightness == 100: + return 255 + # Convert Abode brightness (0-99) to HASS brightness (0-255) + return ceil(brightness * 255 / 99.0) @property def hs_color(self): diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py new file mode 100644 index 00000000000..a66e219c1a8 --- /dev/null +++ b/homeassistant/components/light/eufy.py @@ -0,0 +1,168 @@ +""" +Support for Eufy lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.eufy/ +""" +import logging + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +import homeassistant.util.color as color_util + +from homeassistant.util.color import ( + color_temperature_mired_to_kelvin as mired_to_kelvin, + color_temperature_kelvin_to_mired as kelvin_to_mired) + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + +EUFY_MAX_KELVIN = 6500 +EUFY_MIN_KELVIN = 2700 + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy bulbs.""" + if discovery_info is None: + return + add_devices([EufyLight(discovery_info)], True) + + +class EufyLight(Light): + """Representation of a Eufy light.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._temp = None + self._brightness = None + self._hs = None + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._bulb = lakeside.bulb(self._address, self._code, self._type) + self._colormode = False + if self._type == "T1011": + self._features = SUPPORT_BRIGHTNESS + elif self._type == "T1012": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP + elif self._type == "T1013": + self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | \ + SUPPORT_COLOR + self._bulb.connect() + + def update(self): + """Synchronise state from the bulb.""" + self._bulb.update() + self._brightness = self._bulb.brightness + self._temp = self._bulb.temperature + if self._bulb.colors: + self._colormode = True + self._hs = color_util.color_RGB_to_hs(*self._bulb.colors) + else: + self._colormode = False + self._state = self._bulb.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._brightness * 255 / 100) + + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return kelvin_to_mired(EUFY_MAX_KELVIN) + + @property + def max_mireds(self): + """Return maximu supported color temperature.""" + return kelvin_to_mired(EUFY_MIN_KELVIN) + + @property + def color_temp(self): + """Return the color temperature of this light.""" + temp_in_k = int(EUFY_MIN_KELVIN + (self._temp * + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN) + / 100)) + return kelvin_to_mired(temp_in_k) + + @property + def hs_color(self): + """Return the color of this light.""" + if not self._colormode: + return None + return self._hs + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + brightness = kwargs.get(ATTR_BRIGHTNESS) + colortemp = kwargs.get(ATTR_COLOR_TEMP) + # pylint: disable=invalid-name + hs = kwargs.get(ATTR_HS_COLOR) + + if brightness is not None: + brightness = int(brightness * 100 / 255) + else: + brightness = max(1, self._brightness) + + if colortemp is not None: + self._colormode = False + temp_in_k = mired_to_kelvin(colortemp) + relative_temp = temp_in_k - EUFY_MIN_KELVIN + temp = int(relative_temp * 100 / + (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN)) + else: + temp = None + + if hs is not None: + rgb = color_util.color_hsv_to_RGB( + hs[0], hs[1], brightness / 255 * 100) + self._colormode = True + elif self._colormode: + rgb = color_util.color_hsv_to_RGB( + self._hs[0], self._hs[1], brightness / 255 * 100) + else: + rgb = None + + try: + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=True, brightness=brightness, + temperature=temp, colors=rgb) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + try: + self._bulb.set_state(power=False) + except BrokenPipeError: + self._bulb.connect() + self._bulb.set_state(power=False) diff --git a/homeassistant/components/light/hive.py b/homeassistant/components/light/hive.py index c4ecc5a9d2c..1fd9e8aaaca 100644 --- a/homeassistant/components/light/hive.py +++ b/homeassistant/components/light/hive.py @@ -34,6 +34,7 @@ class HiveDeviceLight(Light): self.device_type = hivedevice["HA_DeviceType"] self.light_device_type = hivedevice["Hive_Light_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -48,6 +49,11 @@ class HiveDeviceLight(Light): """Return the display name of this light.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def brightness(self): """Brightness of the light (an integer in the range 1-255).""" @@ -136,3 +142,5 @@ class HiveDeviceLight(Light): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py new file mode 100644 index 00000000000..e6dc09e455c --- /dev/null +++ b/homeassistant/components/light/homekit_controller.py @@ -0,0 +1,134 @@ +""" +Support for Homekit lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import ( + HomeKitEntity, KNOWN_ACCESSORIES) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, SUPPORT_BRIGHTNESS, + SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light) + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit lighting.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitLight(accessory, discovery_info)], True) + + +class HomeKitLight(HomeKitEntity, Light): + """Representation of a Homekit light.""" + + def __init__(self, *args): + """Initialise the light.""" + super().__init__(*args) + self._on = None + self._brightness = None + self._color_temperature = None + self._hue = None + self._saturation = None + + def update_characteristics(self, characteristics): + """Synchronise light state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == 'brightness': + self._chars['brightness'] = characteristic['iid'] + self._features |= SUPPORT_BRIGHTNESS + self._brightness = characteristic['value'] + elif ctype == 'color-temperature': + self._chars['color_temperature'] = characteristic['iid'] + self._features |= SUPPORT_COLOR_TEMP + self._color_temperature = characteristic['value'] + elif ctype == "hue": + self._chars['hue'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._hue = characteristic['value'] + elif ctype == "saturation": + self._chars['saturation'] = characteristic['iid'] + self._features |= SUPPORT_COLOR + self._saturation = characteristic['value'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + if self._features & SUPPORT_BRIGHTNESS: + return self._brightness * 255 / 100 + return None + + @property + def hs_color(self): + """Return the color property.""" + if self._features & SUPPORT_COLOR: + return (self._hue, self._saturation) + return None + + @property + def color_temp(self): + """Return the color temperature.""" + if self._features & SUPPORT_COLOR_TEMP: + return self._color_temperature + return None + + @property + def supported_features(self): + """Flag supported features.""" + return self._features + + def turn_on(self, **kwargs): + """Turn the specified light on.""" + hs_color = kwargs.get(ATTR_HS_COLOR) + temperature = kwargs.get(ATTR_COLOR_TEMP) + brightness = kwargs.get(ATTR_BRIGHTNESS) + + characteristics = [] + if hs_color is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['hue'], + 'value': hs_color[0]}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['saturation'], + 'value': hs_color[1]}) + if brightness is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['brightness'], + 'value': int(brightness * 100 / 255)}) + + if temperature is not None: + characteristics.append({'aid': self._aid, + 'iid': self._chars['color-temperature'], + 'value': int(temperature)}) + characteristics.append({'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}) + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified light off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 1701b886b68..6eb8de99c99 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -49,11 +49,17 @@ GROUP_MIN_API_VERSION = (1, 13, 0) async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the Hue lights.""" - if discovery_info is None: - return + """Old way of setting up Hue lights. - bridge = hass.data[hue.DOMAIN][discovery_info['host']] + Can only be called when a user accidentally mentions hue platform in their + config. But even in that case it would have been ignored. + """ + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the Hue lights from a config entry.""" + bridge = hass.data[hue.DOMAIN][config_entry.data['host']] cur_lights = {} cur_groups = {} diff --git a/homeassistant/components/light/nanoleaf_aurora.py b/homeassistant/components/light/nanoleaf_aurora.py index 2a9066bd55f..99c07166037 100644 --- a/homeassistant/components/light/nanoleaf_aurora.py +++ b/homeassistant/components/light/nanoleaf_aurora.py @@ -1,11 +1,6 @@ """ Support for Nanoleaf Aurora platform. -Based in large parts upon Software-2's ha-aurora and fully -reliant on Software-2's nanoleaf-aurora Python Library, see -https://github.com/software-2/ha-aurora as well as -https://github.com/software-2/nanoleaf - For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.nanoleaf_aurora/ """ @@ -15,9 +10,9 @@ import voluptuous as vol from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, - SUPPORT_EFFECT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, - SUPPORT_COLOR, PLATFORM_SCHEMA, Light) -from homeassistant.const import CONF_HOST, CONF_TOKEN, CONF_NAME + PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, + SUPPORT_EFFECT, Light) +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.util import color as color_util from homeassistant.util.color import \ @@ -25,20 +20,24 @@ from homeassistant.util.color import \ REQUIREMENTS = ['nanoleaf==0.4.1'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Aurora' + +ICON = 'mdi:triangle-outline' + SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | SUPPORT_COLOR) -_LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_TOKEN): cv.string, - vol.Optional(CONF_NAME, default='Aurora'): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup Nanoleaf Aurora device.""" + """Set up the Nanoleaf Aurora device.""" import nanoleaf host = config.get(CONF_HOST) name = config.get(CONF_NAME) @@ -47,8 +46,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): aurora_light.hass_name = name if aurora_light.on is None: - _LOGGER.error("Could not connect to \ - Nanoleaf Aurora: %s on %s", name, host) + _LOGGER.error( + "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + return + add_devices([AuroraLight(aurora_light)], True) @@ -56,7 +57,7 @@ class AuroraLight(Light): """Representation of a Nanoleaf Aurora.""" def __init__(self, light): - """Initialize an Aurora.""" + """Initialize an Aurora light.""" self._brightness = None self._color_temp = None self._effect = None @@ -99,7 +100,7 @@ class AuroraLight(Light): @property def icon(self): """Return the icon to use in the frontend, if any.""" - return "mdi:triangle-outline" + return ICON @property def is_on(self): @@ -141,10 +142,7 @@ class AuroraLight(Light): self._light.on = False def update(self): - """Fetch new state data for this light. - - This is the only method that should fetch new data for Home Assistant. - """ + """Fetch new state data for this light.""" self._brightness = self._light.brightness self._color_temp = self._light.color_temperature self._effect = self._light.effect diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py index 26741525b8f..528f4f73c53 100644 --- a/homeassistant/components/light/qwikswitch.py +++ b/homeassistant/components/light/qwikswitch.py @@ -27,9 +27,9 @@ class QSLight(QSToggleEntity, Light): @property def brightness(self): """Return the brightness of this light (0-255).""" - return self._qsusb[self.qsid, 1] if self._dim else None + return self.device.value if self.device.is_dimmer else None @property def supported_features(self): """Flag supported features.""" - return SUPPORT_BRIGHTNESS if self._dim else 0 + return SUPPORT_BRIGHTNESS if self.device.is_dimmer else 0 diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 7061c24aac6..d6d860cbd9e 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -24,6 +24,14 @@ REQUIREMENTS = ['yeelight==0.4.0'] _LOGGER = logging.getLogger(__name__) +LEGACY_DEVICE_TYPE_MAP = { + 'color1': 'rgb', + 'mono1': 'white', + 'strip1': 'strip', + 'bslamp1': 'bedside', + 'ceiling1': 'ceiling', +} + CONF_TRANSITION = 'transition' DEFAULT_TRANSITION = 350 @@ -122,8 +130,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if discovery_info is not None: _LOGGER.debug("Adding autodetected %s", discovery_info['hostname']) + device_type = discovery_info['device_type'] + device_type = LEGACY_DEVICE_TYPE_MAP.get(device_type, device_type) + # Not using hostname, as it seems to vary. - name = "yeelight_%s_%s" % (discovery_info['device_type'], + name = "yeelight_%s_%s" % (device_type, discovery_info['properties']['mac']) device = {'name': name, 'ipaddr': discovery_info['host']} diff --git a/homeassistant/components/lock/bmw_connected_drive.py b/homeassistant/components/lock/bmw_connected_drive.py index c992bf1225a..52734b1259c 100644 --- a/homeassistant/components/lock/bmw_connected_drive.py +++ b/homeassistant/components/lock/bmw_connected_drive.py @@ -38,6 +38,7 @@ class BMWLock(LockDevice): self._vehicle = vehicle self._attribute = attribute self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._state = None @@ -49,6 +50,11 @@ class BMWLock(LockDevice): """ return False + @property + def unique_id(self): + """Return the unique ID of the lock.""" + return self._unique_id + @property def name(self): """Return the name of the lock.""" diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 63f0315f35c..7b1b7417cfd 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylutron-caseta==0.3.0'] +REQUIREMENTS = ['pylutron-caseta==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index a0a8db6ba4d..cf5091fc308 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -22,12 +22,22 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 62910 DOMAIN = 'maxcube' -MAXCUBE_HANDLE = 'maxcube' +DATA_KEY = 'maxcube' + +NOTIFICATION_ID = 'maxcube_notification' +NOTIFICATION_TITLE = 'Max!Cube gateway setup' + +CONF_GATEWAYS = 'gateways' + +CONFIG_GATEWAY = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_GATEWAYS, default={}): + vol.All(cv.ensure_list, [CONFIG_GATEWAY]) }), }, extra=vol.ALLOW_EXTRA) @@ -36,19 +46,31 @@ def setup(hass, config): """Establish connection to MAX! Cube.""" from maxcube.connection import MaxCubeConnection from maxcube.cube import MaxCube + if DATA_KEY not in hass.data: + hass.data[DATA_KEY] = {} - host = config.get(DOMAIN).get(CONF_HOST) - port = config.get(DOMAIN).get(CONF_PORT) + connection_failed = 0 + gateways = config[DOMAIN][CONF_GATEWAYS] + for gateway in gateways: + host = gateway[CONF_HOST] + port = gateway[CONF_PORT] - try: - cube = MaxCube(MaxCubeConnection(host, port)) - except timeout: - _LOGGER.error("Connection to Max!Cube could not be established") - cube = None + try: + cube = MaxCube(MaxCubeConnection(host, port)) + hass.data[DATA_KEY][host] = MaxCubeHandle(cube) + except timeout as ex: + _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart Home Assistant after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + connection_failed += 1 + + if connection_failed >= len(gateways): return False - hass.data[MAXCUBE_HANDLE] = MaxCubeHandle(cube) - load_platform(hass, 'climate', DOMAIN) load_platform(hass, 'binary_sensor', DOMAIN) diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 85c569789a2..b5fd26b0bcb 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.04.03'] +REQUIREMENTS = ['youtube_dl==2018.04.16'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py new file mode 100644 index 00000000000..37b3c0ff819 --- /dev/null +++ b/homeassistant/components/media_player/blackbird.py @@ -0,0 +1,213 @@ +""" +Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.blackbird +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + DOMAIN, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_NAME, CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyblackbird==0.5'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_BLACKBIRD = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE + +ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +SOURCE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, +}) + +CONF_ZONES = 'zones' +CONF_SOURCES = 'sources' +CONF_TYPE = 'type' + +DATA_BLACKBIRD = 'blackbird' + +SERVICE_SETALLZONES = 'blackbird_set_all_zones' +ATTR_SOURCE = 'source' + +BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_SOURCE): cv.string +}) + + +# Valid zone ids: 1-8 +ZONE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +# Valid source ids: 1-8 +SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8)) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TYPE): vol.In(['serial', 'socket']), + vol.Optional(CONF_PORT): cv.string, + vol.Optional(CONF_HOST): cv.string, + vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}), + vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}), +}) + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Monoprice Blackbird 4k 8x8 HDBaseT Matrix platform.""" + port = config.get(CONF_PORT) + host = config.get(CONF_HOST) + device_type = config.get(CONF_TYPE) + + import socket + from pyblackbird import get_blackbird + from serial import SerialException + + if device_type == 'serial': + if port is None: + _LOGGER.error("No port configured") + return + try: + blackbird = get_blackbird(port) + except SerialException: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + elif device_type == 'socket': + try: + if host is None: + _LOGGER.error("No host configured") + return + blackbird = get_blackbird(host, False) + except socket.timeout: + _LOGGER.error("Error connecting to the Blackbird controller") + return + + else: + _LOGGER.error("Incorrect device type specified") + return + + sources = {source_id: extra[CONF_NAME] for source_id, extra + in config[CONF_SOURCES].items()} + + hass.data[DATA_BLACKBIRD] = [] + for zone_id, extra in config[CONF_ZONES].items(): + _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME]) + hass.data[DATA_BLACKBIRD].append(BlackbirdZone( + blackbird, sources, zone_id, extra[CONF_NAME])) + + add_devices(hass.data[DATA_BLACKBIRD], True) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + source = service.data.get(ATTR_SOURCE) + if entity_ids: + devices = [device for device in hass.data[DATA_BLACKBIRD] + if device.entity_id in entity_ids] + + else: + devices = hass.data[DATA_BLACKBIRD] + + for device in devices: + if service.service == SERVICE_SETALLZONES: + device.set_all_zones(source) + + hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle, + schema=BLACKBIRD_SETALLZONES_SCHEMA) + + +class BlackbirdZone(MediaPlayerDevice): + """Representation of a Blackbird matrix zone.""" + + def __init__(self, blackbird, sources, zone_id, zone_name): + """Initialize new zone.""" + self._blackbird = blackbird + # dict source_id -> source name + self._source_id_name = sources + # dict source name -> source_id + self._source_name_id = {v: k for k, v in sources.items()} + # ordered list of all source names + self._source_names = sorted(self._source_name_id.keys(), + key=lambda v: self._source_name_id[v]) + self._zone_id = zone_id + self._name = zone_name + self._state = None + self._source = None + + def update(self): + """Retrieve latest state.""" + state = self._blackbird.zone_status(self._zone_id) + if not state: + return False + self._state = STATE_ON if state.power else STATE_OFF + idx = state.av + if idx in self._source_id_name: + self._source = self._source_id_name[idx] + else: + self._source = None + return True + + @property + def name(self): + """Return the name of the zone.""" + return self._name + + @property + def state(self): + """Return the state of the zone.""" + return self._state + + @property + def supported_features(self): + """Return flag of media commands that are supported.""" + return SUPPORT_BLACKBIRD + + @property + def media_title(self): + """Return the current source as media title.""" + return self._source + + @property + def source(self): + """Return the current input source of the device.""" + return self._source + + @property + def source_list(self): + """List of available input sources.""" + return self._source_names + + def set_all_zones(self, source): + """Set all zones to one source.""" + _LOGGER.debug("Setting all zones") + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting all zones source to %s", idx) + self._blackbird.set_all_zone_source(idx) + + def select_source(self, source): + """Set input source.""" + if source not in self._source_name_id: + return + idx = self._source_name_id[source] + _LOGGER.debug("Setting zone %d source to %s", self._zone_id, idx) + self._blackbird.set_zone_source(self._zone_id, idx) + + def turn_on(self): + """Turn the media player on.""" + _LOGGER.debug("Turning zone %d on", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, True) + + def turn_off(self): + """Turn the media player off.""" + _LOGGER.debug("Turning zone %d off", self._zone_id) + self._blackbird.set_zone_power(self._zone_id, False) diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 1b6310d4cab..283c4af032e 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -37,30 +37,30 @@ REQUIREMENTS = ['xmltodict==0.11.0'] _LOGGER = logging.getLogger(__name__) -STATE_GROUPED = 'grouped' - ATTR_MASTER = 'master' -SERVICE_JOIN = 'bluesound_join' -SERVICE_UNJOIN = 'bluesound_unjoin' -SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' -SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' - DATA_BLUESOUND = 'bluesound' DEFAULT_PORT = 11000 -SYNC_STATUS_INTERVAL = timedelta(minutes=5) -UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) -UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) -UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) NODE_OFFLINE_CHECK_TIMEOUT = 180 NODE_RETRY_INITIATION = timedelta(minutes=3) +SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer' +SERVICE_JOIN = 'bluesound_join' +SERVICE_SET_TIMER = 'bluesound_set_sleep_timer' +SERVICE_UNJOIN = 'bluesound_unjoin' +STATE_GROUPED = 'grouped' +SYNC_STATUS_INTERVAL = timedelta(minutes=5) + +UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30) +UPDATE_PRESETS_INTERVAL = timedelta(minutes=30) +UPDATE_SERVICES_INTERVAL = timedelta(minutes=30) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }]) }) @@ -131,8 +131,8 @@ def _add_player(hass, async_add_devices, host, port=None, name=None): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) -async 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 Bluesound platforms.""" if DATA_BLUESOUND not in hass.data: hass.data[DATA_BLUESOUND] = [] @@ -202,6 +202,9 @@ class BluesoundPlayer(MediaPlayerDevice): if self.port is None: self.port = DEFAULT_PORT + class _TimeoutException(Exception): + pass + @staticmethod def _try_get_index(string, search_string): """Get the index.""" @@ -258,7 +261,8 @@ class BluesoundPlayer(MediaPlayerDevice): while True: await self.async_update_status() - except (asyncio.TimeoutError, ClientError): + except (asyncio.TimeoutError, ClientError, + BluesoundPlayer._TimeoutException): _LOGGER.info("Node %s is offline, retrying later", self._name) await asyncio.sleep( NODE_OFFLINE_CHECK_TIMEOUT, loop=self._hass.loop) @@ -293,8 +297,8 @@ class BluesoundPlayer(MediaPlayerDevice): self._retry_remove = async_track_time_interval( self._hass, self.async_init, NODE_RETRY_INITIATION) except Exception: - _LOGGER.exception("Unexpected when initiating error in %s", - self.host) + _LOGGER.exception( + "Unexpected when initiating error in %s", self.host) raise async def async_update(self): @@ -307,8 +311,8 @@ class BluesoundPlayer(MediaPlayerDevice): await self.async_update_captures() await self.async_update_services() - async def send_bluesound_command(self, method, raise_timeout=False, - allow_offline=False): + async def send_bluesound_command( + self, method, raise_timeout=False, allow_offline=False): """Send command to the player.""" import xmltodict @@ -321,6 +325,7 @@ class BluesoundPlayer(MediaPlayerDevice): _LOGGER.debug("Calling URL: %s", url) response = None + try: websession = async_get_clientsession(self._hass) with async_timeout.timeout(10, loop=self._hass.loop): @@ -332,6 +337,9 @@ class BluesoundPlayer(MediaPlayerDevice): data = None else: data = xmltodict.parse(result) + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() else: _LOGGER.error("Error %s on %s", response.status, url) return None @@ -366,13 +374,9 @@ class BluesoundPlayer(MediaPlayerDevice): with async_timeout.timeout(125, loop=self._hass.loop): response = await self._polling_session.get( - url, - headers={CONNECTION: KEEP_ALIVE}) + url, headers={CONNECTION: KEEP_ALIVE}) - if response.status != 200: - _LOGGER.error("Error %s on %s. Trying one more time.", - response.status, url) - else: + if response.status == 200: result = await response.text() self._is_online = True self._last_status_update = dt_util.utcnow() @@ -380,8 +384,8 @@ class BluesoundPlayer(MediaPlayerDevice): group_name = self._status.get('groupName', None) if group_name != self._group_name: - _LOGGER.debug('Group name change detected on device: %s', - self.host) + _LOGGER.debug( + "Group name change detected on device: %s", self.host) self._group_name = group_name # the sleep is needed to make sure that the # devices is synced @@ -398,14 +402,20 @@ class BluesoundPlayer(MediaPlayerDevice): await self.force_update_sync_status() self.async_schedule_update_ha_state() + elif response.status == 595: + _LOGGER.info("Status 595 returned, treating as timeout") + raise BluesoundPlayer._TimeoutException() + else: + _LOGGER.error("Error %s on %s. Trying one more time", + response.status, url) except (asyncio.TimeoutError, ClientError): self._is_online = False self._last_status_update = None self._status = None self.async_schedule_update_ha_state() - _LOGGER.info("Client connection error, marking %s as offline", - self._name) + _LOGGER.info( + "Client connection error, marking %s as offline", self._name) raise async def async_trigger_sync_on_all(self): @@ -416,8 +426,8 @@ class BluesoundPlayer(MediaPlayerDevice): await player.force_update_sync_status() @Throttle(SYNC_STATUS_INTERVAL) - async def async_update_sync_status(self, on_updated_cb=None, - raise_timeout=False): + async def async_update_sync_status( + self, on_updated_cb=None, raise_timeout=False): """Update sync status.""" await self.force_update_sync_status( on_updated_cb, raise_timeout=False) @@ -465,7 +475,7 @@ class BluesoundPlayer(MediaPlayerDevice): 'image': item.get('@image', ''), 'is_raw_url': True, 'url2': item.get('@url', ''), - 'url': 'Preset?id=' + item.get('@id', '') + 'url': 'Preset?id={}'.format(item.get('@id', '')) }) if 'presets' in resp and 'preset' in resp['presets']: @@ -503,11 +513,6 @@ class BluesoundPlayer(MediaPlayerDevice): return self._services_items - @property - def should_poll(self): - """No need to poll information.""" - return True - @property def media_content_type(self): """Content type of current playing media.""" @@ -803,22 +808,22 @@ class BluesoundPlayer(MediaPlayerDevice): async def async_add_slave(self, slave_device): """Add slave to master.""" - return self.send_bluesound_command('/AddSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/AddSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) async def async_remove_slave(self, slave_device): """Remove slave to master.""" - return self.send_bluesound_command('/RemoveSlave?slave={}&port={}' - .format(slave_device.host, - slave_device.port)) + return await self.send_bluesound_command( + '/RemoveSlave?slave={}&port={}'.format( + slave_device.host, slave_device.port)) async def async_increase_timer(self): """Increase sleep time on player.""" sleep_time = await self.send_bluesound_command('/Sleep') if sleep_time is None: - _LOGGER.error('Error while increasing sleep time on player: %s', - self.host) + _LOGGER.error( + "Error while increasing sleep time on player: %s", self.host) return 0 return int(sleep_time.get('sleep', '0')) @@ -831,8 +836,9 @@ class BluesoundPlayer(MediaPlayerDevice): async def async_set_shuffle(self, shuffle): """Enable or disable shuffle mode.""" - return self.send_bluesound_command('/Shuffle?state={}' - .format('1' if shuffle else '0')) + value = '1' if shuffle else '0' + return await self.send_bluesound_command( + '/Shuffle?state={}'.format(value)) async def async_select_source(self, source): """Select input source.""" @@ -856,14 +862,14 @@ class BluesoundPlayer(MediaPlayerDevice): if 'is_raw_url' in selected_source and selected_source['is_raw_url']: url = selected_source['url'] - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) async def async_clear_playlist(self): """Clear players playlist.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Clear') + return await self.send_bluesound_command('Clear') async def async_media_next_track(self): """Send media_next command to media player.""" @@ -877,7 +883,7 @@ class BluesoundPlayer(MediaPlayerDevice): action['@name'] == 'skip'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) async def async_media_previous_track(self): """Send media_previous command to media player.""" @@ -891,35 +897,36 @@ class BluesoundPlayer(MediaPlayerDevice): action['@name'] == 'back'): cmd = action['@url'] - return self.send_bluesound_command(cmd) + return await self.send_bluesound_command(cmd) async def async_media_play(self): """Send media_play command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play') + return await self.send_bluesound_command('Play') async def async_media_pause(self): """Send media_pause command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') async def async_media_stop(self): """Send stop command.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Pause') + return await self.send_bluesound_command('Pause') async def async_media_seek(self, position): """Send media_seek command to media player.""" if self.is_grouped and not self.is_master: return - return self.send_bluesound_command('Play?seek=' + str(float(position))) + return await self.send_bluesound_command( + 'Play?seek={}'.format(float(position))) async def async_play_media(self, media_type, media_id, **kwargs): """ @@ -933,9 +940,9 @@ class BluesoundPlayer(MediaPlayerDevice): url = 'Play?url={}'.format(media_id) if kwargs.get(ATTR_MEDIA_ENQUEUE): - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) - return self.send_bluesound_command(url) + return await self.send_bluesound_command(url) async def async_volume_up(self): """Volume up the media player.""" @@ -957,7 +964,7 @@ class BluesoundPlayer(MediaPlayerDevice): volume = 0 elif volume > 1: volume = 1 - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(volume) * 100)) async def async_mute_volume(self, mute): @@ -966,7 +973,7 @@ class BluesoundPlayer(MediaPlayerDevice): volume = self.volume_level if volume > 0: self._lastvol = volume - return self.send_bluesound_command('Volume?level=0') + return await self.send_bluesound_command('Volume?level=0') else: - return self.send_bluesound_command( + return await self.send_bluesound_command( 'Volume?level=' + str(float(self._lastvol) * 100)) diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index 2edda0645b0..30d4bd166d0 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -288,7 +288,8 @@ class CastDevice(MediaPlayerDevice): self._chromecast = None # type: Optional[pychromecast.Chromecast] self.cast_status = None self.media_status = None - self.media_status_received = None + self.media_status_position = None + self.media_status_position_received = None self._available = False # type: bool self._status_listener = None # type: Optional[CastStatusListener] @@ -361,7 +362,8 @@ class CastDevice(MediaPlayerDevice): self._chromecast = None self.cast_status = None self.media_status = None - self.media_status_received = None + self.media_status_position = None + self.media_status_position_received = None self._status_listener.invalidate() self._status_listener = None @@ -388,8 +390,36 @@ class CastDevice(MediaPlayerDevice): def new_media_status(self, media_status): """Handle updates of the media status.""" + # Only use media position for playing/paused, + # and for normal playback rate + if (media_status is None or + abs(media_status.playback_rate - 1) > 0.01 or + not (media_status.player_is_playing or + media_status.player_is_paused)): + self.media_status_position = None + self.media_status_position_received = None + else: + # Avoid unnecessary state attribute updates if player_state and + # calculated position stay the same + now = dt_util.utcnow() + do_update = \ + (self.media_status is None or + self.media_status_position is None or + self.media_status.player_state != media_status.player_state) + if not do_update: + if media_status.player_is_playing: + elapsed = now - self.media_status_position_received + do_update = abs(media_status.current_time - + (self.media_status_position + + elapsed.total_seconds())) > 1 + else: + do_update = \ + self.media_status_position != media_status.current_time + if do_update: + self.media_status_position = media_status.current_time + self.media_status_position_received = now + self.media_status = media_status - self.media_status_received = dt_util.utcnow() self.schedule_update_ha_state() def new_connection_status(self, connection_status): @@ -595,13 +625,7 @@ class CastDevice(MediaPlayerDevice): @property def media_position(self): """Position of current playing media in seconds.""" - if self.media_status is None or \ - not (self.media_status.player_is_playing or - self.media_status.player_is_paused or - self.media_status.player_is_idle): - return None - - return self.media_status.current_time + return self.media_status_position @property def media_position_updated_at(self): @@ -609,7 +633,7 @@ class CastDevice(MediaPlayerDevice): Returns value from homeassistant.util.dt.utcnow(). """ - return self.media_status_received + return self.media_status_position_received @property def unique_id(self) -> Optional[str]: diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 9f2a653b8ee..770d57b5b8e 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -8,6 +8,7 @@ import asyncio from collections import OrderedDict from functools import wraps import logging +import socket import urllib import re @@ -157,13 +158,29 @@ def _check_deprecated_turn_off(hass, turn_off_action): def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Kodi platform.""" if DATA_KODI not in hass.data: - hass.data[DATA_KODI] = [] - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - tcp_port = config.get(CONF_TCP_PORT) - encryption = config.get(CONF_PROXY_SSL) - websocket = config.get(CONF_ENABLE_WEBSOCKET) + hass.data[DATA_KODI] = dict() + + # Is this a manual configuration? + if discovery_info is None: + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + tcp_port = config.get(CONF_TCP_PORT) + encryption = config.get(CONF_PROXY_SSL) + websocket = config.get(CONF_ENABLE_WEBSOCKET) + else: + name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname')) + host = discovery_info.get('host') + port = discovery_info.get('port') + tcp_port = DEFAULT_TCP_PORT + encryption = DEFAULT_PROXY_SSL + websocket = DEFAULT_ENABLE_WEBSOCKET + + # Only add a device once, so discovered devices do not override manual + # config. + ip_addr = socket.gethostbyname(host) + if ip_addr in hass.data[DATA_KODI]: + return entity = KodiDevice( hass, @@ -175,7 +192,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): turn_off_action=config.get(CONF_TURN_OFF_ACTION), timeout=config.get(CONF_TIMEOUT), websocket=websocket) - hass.data[DATA_KODI].append(entity) + hass.data[DATA_KODI][ip_addr] = entity async_add_devices([entity], update_before_add=True) @asyncio.coroutine @@ -189,10 +206,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if key != 'entity_id'} entity_ids = service.data.get('entity_id') if entity_ids: - target_players = [player for player in hass.data[DATA_KODI] + target_players = [player + for player in hass.data[DATA_KODI].values() if player.entity_id in entity_ids] else: - target_players = hass.data[DATA_KODI] + target_players = hass.data[DATA_KODI].values() update_tasks = [] for player in target_players: diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py index a6d5841bb0f..f5b7567aa34 100644 --- a/homeassistant/components/media_player/mediaroom.py +++ b/homeassistant/components/media_player/mediaroom.py @@ -8,6 +8,7 @@ import logging import voluptuous as vol +from homeassistant.core import callback from homeassistant.components.media_player import ( MEDIA_TYPE_CHANNEL, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_STOP, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, @@ -20,11 +21,11 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, STATE_OFF, CONF_TIMEOUT, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNAVAILABLE + STATE_UNAVAILABLE, EVENT_HOMEASSISTANT_STOP ) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymediaroom==0.6'] +REQUIREMENTS = ['pymediaroom==0.6.3'] _LOGGER = logging.getLogger(__name__) @@ -81,12 +82,21 @@ async def async_setup_platform(hass, config, async_add_devices, if not config[CONF_OPTIMISTIC]: from pymediaroom import install_mediaroom_protocol - already_installed = hass.data.get(DISCOVERY_MEDIAROOM, False) + already_installed = hass.data.get(DISCOVERY_MEDIAROOM, None) if not already_installed: - await install_mediaroom_protocol( + hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol( responses_callback=callback_notify) + + @callback + def stop_discovery(event): + """Stop discovery of new mediaroom STB's.""" + _LOGGER.debug("Stopping internal pymediaroom discovery.") + hass.data[DISCOVERY_MEDIAROOM].close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + stop_discovery) + _LOGGER.debug("Auto discovery installed") - hass.data[DISCOVERY_MEDIAROOM] = True class MediaroomDevice(MediaPlayerDevice): @@ -120,7 +130,7 @@ class MediaroomDevice(MediaPlayerDevice): self._channel = None self._optimistic = optimistic self._state = STATE_PLAYING if optimistic else STATE_STANDBY - self._name = 'Mediaroom {}'.format(device_id) + self._name = 'Mediaroom {}'.format(device_id if device_id else host) self._available = True if device_id: self._unique_id = device_id diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 81a18ab93c5..04dd1ac5f2e 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -23,7 +23,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['python-mpd2==0.5.5'] +REQUIREMENTS = ['python-mpd2==1.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 432d9ce108f..58703165385 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -22,6 +22,7 @@ REQUIREMENTS = ['onkyo-eiscp==1.2.4'] _LOGGER = logging.getLogger(__name__) CONF_SOURCES = 'sources' +CONF_ZONE2 = 'zone2' DEFAULT_NAME = 'Onkyo Receiver' @@ -40,6 +41,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): {cv.string: cv.string}, + vol.Optional(CONF_ZONE2, default=False): cv.boolean, }) @@ -57,6 +59,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): eiscp.eISCP(host), config.get(CONF_SOURCES), name=config.get(CONF_NAME))) KNOWN_HOSTS.append(host) + + # Add Zone2 if configured + if config.get(CONF_ZONE2): + _LOGGER.debug("Setting up zone 2") + hosts.append(OnkyoDeviceZone2(eiscp.eISCP(host), + config.get(CONF_SOURCES), + name=config.get(CONF_NAME) + + " Zone 2")) except OSError: _LOGGER.error("Unable to connect to receiver at %s", host) else: @@ -98,8 +108,9 @@ class OnkyoDevice(MediaPlayerDevice): return result def update(self): - """Get the latest details from the device.""" + """Get the latest state from the device.""" status = self.command('system-power query') + if not status: return if status[1] == 'on': @@ -107,9 +118,11 @@ class OnkyoDevice(MediaPlayerDevice): else: self._pwstate = STATE_OFF return + volume_raw = self.command('volume query') mute_raw = self.command('audio-muting query') current_source_raw = self.command('input-selector query') + if not (volume_raw and mute_raw and current_source_raw): return @@ -147,12 +160,12 @@ class OnkyoDevice(MediaPlayerDevice): @property def is_volume_muted(self): - """Boolean if volume is currently muted.""" + """Return boolean indicating mute status.""" return self._muted @property def supported_features(self): - """Flag media player features that are supported.""" + """Return media player features that are supported.""" return SUPPORT_ONKYO @property @@ -166,7 +179,7 @@ class OnkyoDevice(MediaPlayerDevice): return self._source_list def turn_off(self): - """Turn off media player.""" + """Turn the media player off.""" self.command('system-power standby') def set_volume_level(self, volume): @@ -189,3 +202,68 @@ class OnkyoDevice(MediaPlayerDevice): if source in self._source_list: source = self._reverse_mapping[source] self.command('input-selector {}'.format(source)) + + +class OnkyoDeviceZone2(OnkyoDevice): + """Representation of an Onkyo device's zone 2.""" + + def update(self): + """Get the latest state from the device.""" + status = self.command('zone2.power=query') + + if not status: + return + if status[1] == 'on': + self._pwstate = STATE_ON + else: + self._pwstate = STATE_OFF + return + + volume_raw = self.command('zone2.volume=query') + mute_raw = self.command('zone2.muting=query') + current_source_raw = self.command('zone2.selector=query') + + if not (volume_raw and mute_raw and current_source_raw): + return + + # eiscp can return string or tuple. Make everything tuples. + if isinstance(current_source_raw[1], str): + current_source_tuples = \ + (current_source_raw[0], (current_source_raw[1],)) + else: + current_source_tuples = current_source_raw + + for source in current_source_tuples[1]: + if source in self._source_mapping: + self._current_source = self._source_mapping[source] + break + else: + self._current_source = '_'.join( + [i for i in current_source_tuples[1]]) + self._muted = bool(mute_raw[1] == 'on') + self._volume = volume_raw[1] / 80.0 + + def turn_off(self): + """Turn the media player off.""" + self.command('zone2.power=standby') + + def set_volume_level(self, volume): + """Set volume level, input is range 0..1. Onkyo ranges from 1-80.""" + self.command('zone2.volume={}'.format(int(volume*80))) + + def mute_volume(self, mute): + """Mute (true) or unmute (false) media player.""" + if mute: + self.command('zone2.muting=on') + else: + self.command('zone2.muting=off') + + def turn_on(self): + """Turn the media player on.""" + self.command('zone2.power=on') + + def select_source(self, source): + """Set the input source.""" + if source in self._source_list: + source = self._reverse_mapping[source] + self.command('zone2.selector={}'.format(source)) diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 95072f0270c..0a6c413a688 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -402,3 +402,13 @@ songpal_set_sound_setting: value: description: Value to set. example: 'on' + +blackbird_set_all_zones: + description: Set all Blackbird zones to a single source. + fields: + entity_id: + description: Name of any blackbird zone. + example: 'media_player.zone_1' + source: + description: Name of source to switch to. + example: 'Source 1' diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 86b4087ca81..371ad890364 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -266,6 +266,8 @@ class SqueezeBoxDevice(MediaPlayerDevice): if response is False: return + last_media_position = self.media_position + self._status = {} try: @@ -278,7 +280,11 @@ class SqueezeBoxDevice(MediaPlayerDevice): pass self._status.update(response) - self._last_update = utcnow() + + if self.media_position != last_media_position: + _LOGGER.debug('Media position updated for %s: %s', + self, self.media_position) + self._last_update = utcnow() @property def volume_level(self): diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index ae9d259a47c..d7682a611b9 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -344,6 +344,42 @@ class LgWebOSDevice(MediaPlayerDevice): self._current_source = source_dict['label'] self._client.set_input(source_dict['id']) + def play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + _LOGGER.debug( + "Call play media type <%s>, Id <%s>", media_type, media_id) + + if media_type == MEDIA_TYPE_CHANNEL: + _LOGGER.debug("Searching channel...") + partial_match_channel_id = None + + for channel in self._client.get_channels(): + _LOGGER.debug( + "Checking channel number <%s>, name <%s>, id <%s>...", + channel['channelNumber'], + channel['channelName'], + channel['channelId']) + if media_id == channel['channelNumber']: + _LOGGER.debug( + "Perfect match on channel number: switching!") + self._client.set_channel(channel['channelId']) + return + elif media_id.lower() == channel['channelName'].lower(): + _LOGGER.debug( + "Perfect match on channel name: switching!") + self._client.set_channel(channel['channelId']) + return + elif media_id.lower() in channel['channelName'].lower(): + _LOGGER.debug( + "Partial match on channel name: saving it...") + partial_match_channel_id = channel['channelId'] + + if partial_match_channel_id is not None: + _LOGGER.debug( + "Using partial match on channel name: switching!") + self._client.set_channel(partial_match_channel_id) + return + def media_play(self): """Send play command.""" self._playing = True diff --git a/homeassistant/components/notify/clicksend.py b/homeassistant/components/notify/clicksend.py index 2b2cb4e7f22..c028da2c579 100644 --- a/homeassistant/components/notify/clicksend.py +++ b/homeassistant/components/notify/clicksend.py @@ -37,7 +37,8 @@ PLATFORM_SCHEMA = vol.Schema( vol.All(PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_RECIPIENT): cv.string, + vol.Required(CONF_RECIPIENT, default=[]): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_SENDER): cv.string, }), validate_sender)) @@ -59,21 +60,19 @@ class ClicksendNotificationService(BaseNotificationService): """Initialize the service.""" self.username = config.get(CONF_USERNAME) self.api_key = config.get(CONF_API_KEY) - self.recipient = config.get(CONF_RECIPIENT) + self.recipients = config.get(CONF_RECIPIENT) self.sender = config.get(CONF_SENDER, CONF_RECIPIENT) def send_message(self, message="", **kwargs): """Send a message to a user.""" - data = ({ - 'messages': [ - { - 'source': 'hass.notify', - 'from': self.sender, - 'to': self.recipient, - 'body': message, - } - ] - }) + data = {"messages": []} + for recipient in self.recipients: + data["messages"].append({ + 'source': 'hass.notify', + 'from': self.sender, + 'to': recipient, + 'body': message, + }) api_url = "{}/sms/send".format(BASE_API_URL) diff --git a/homeassistant/components/notify/facebook.py b/homeassistant/components/notify/facebook.py index 791440fdb5b..b73f845ea17 100644 --- a/homeassistant/components/notify/facebook.py +++ b/homeassistant/components/notify/facebook.py @@ -4,6 +4,7 @@ Facebook platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.facebook/ """ +import json import logging from aiohttp.hdrs import CONTENT_TYPE @@ -19,6 +20,8 @@ _LOGGER = logging.getLogger(__name__) CONF_PAGE_ACCESS_TOKEN = 'page_access_token' BASE_URL = 'https://graph.facebook.com/v2.6/me/messages' +CREATE_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/message_creatives' +SEND_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/broadcast_messages' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string, @@ -55,27 +58,60 @@ class FacebookNotificationService(BaseNotificationService): _LOGGER.error("At least 1 target is required") return - for target in targets: - # If the target starts with a "+", we suppose it's a phone number, - # otherwise it's a user id. - if target.startswith('+'): - recipient = {"phone_number": target} - else: - recipient = {"id": target} + # broadcast message + if targets[0].lower() == 'broadcast': + broadcast_create_body = {"messages": [body_message]} + _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) - body = { - "recipient": recipient, - "message": body_message + resp = requests.post(CREATE_BROADCAST_URL, + data=json.dumps(broadcast_create_body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + _LOGGER.debug("FB Messager broadcast id %s : ", resp.json()) + + # at this point we get broadcast id + broadcast_body = { + "message_creative_id": resp.json().get('message_creative_id'), + "notification_type": "REGULAR", } - import json - resp = requests.post(BASE_URL, data=json.dumps(body), + + resp = requests.post(SEND_BROADCAST_URL, + data=json.dumps(broadcast_body), params=payload, headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10) if resp.status_code != 200: - obj = resp.json() - error_message = obj['error']['message'] - error_code = obj['error']['code'] - _LOGGER.error( - "Error %s : %s (Code %s)", resp.status_code, error_message, - error_code) + log_error(resp) + + # non-broadcast message + else: + for target in targets: + # If the target starts with a "+", it's a phone number, + # otherwise it's a user id. + if target.startswith('+'): + recipient = {"phone_number": target} + else: + recipient = {"id": target} + + body = { + "recipient": recipient, + "message": body_message + } + resp = requests.post(BASE_URL, data=json.dumps(body), + params=payload, + headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + timeout=10) + if resp.status_code != 200: + log_error(resp) + + +def log_error(response): + """Log error message.""" + obj = response.json() + error_message = obj['error']['message'] + error_code = obj['error']['code'] + + _LOGGER.error( + "Error %s : %s (Code %s)", response.status_code, error_message, + error_code) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 806acdb6d09..12ddf49fca8 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -76,8 +76,6 @@ def send_message(sender, password, recipient, use_tls, """Initialize the Jabber Bot.""" super(SendNotificationBot, self).__init__(sender, password) - logging.basicConfig(level=logging.ERROR) - self.use_tls = use_tls self.use_ipv6 = False self.add_event_handler('failed_auth', self.check_credentials) diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index f9629ca726a..dc1cbd945a7 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -185,6 +185,9 @@ class Metrics(object): unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) metric = state.entity_id.split(".")[1] + if '_' not in str(metric): + metric = state.entity_id.replace('.', '_') + try: int(metric.split("_")[-1]) metric = "_".join(metric.split("_")[:-1]) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 708eff7cf11..3dc16f513dc 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.light import ATTR_BRIGHTNESS import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyqwikswitch==0.6'] +REQUIREMENTS = ['pyqwikswitch==0.71'] _LOGGER = logging.getLogger(__name__) @@ -34,17 +34,54 @@ CONFIG_SCHEMA = vol.Schema({ vol.Coerce(str), vol.Optional(CONF_DIMMER_ADJUST, default=1): CV_DIM_VALUE, vol.Optional(CONF_BUTTON_EVENTS, default=[]): cv.ensure_list_csv, - vol.Optional(CONF_SENSORS, default={}): vol.Schema({cv.slug: str}), + vol.Optional(CONF_SENSORS, default=[]): vol.All( + cv.ensure_list, [vol.Schema({ + vol.Required('id'): str, + vol.Optional('channel', default=1): int, + vol.Required('name'): str, + vol.Required('type'): str, + })]), vol.Optional(CONF_SWITCHES, default=[]): vol.All( cv.ensure_list, [str]) })}, extra=vol.ALLOW_EXTRA) -class QSToggleEntity(Entity): - """Representation of a Qwikswitch Entity. +class QSEntity(Entity): + """Qwikswitch Entity base.""" - Implement base QS methods. Modeled around HA ToggleEntity[1] & should only - be used in a class that extends both QSToggleEntity *and* ToggleEntity. + def __init__(self, qsid, name): + """Initialize the QSEntity.""" + self._name = name + self.qsid = qsid + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def poll(self): + """QS sensors gets packets in update_packet.""" + return False + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}".format(self.qsid) + + @callback + def update_packet(self, packet): + """Receive update packet from QSUSB. Match dispather_send signature.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Listen for updates from QSUSb via dispatcher.""" + self.hass.helpers.dispatcher.async_dispatcher_connect( + self.qsid, self.update_packet) + + +class QSToggleEntity(QSEntity): + """Representation of a Qwikswitch Toggle Entity. Implemented: - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1]) @@ -57,52 +94,28 @@ class QSToggleEntity(Entity): def __init__(self, qsid, qsusb): """Initialize the ToggleEntity.""" - from pyqwikswitch import (QS_NAME, QSDATA, QS_TYPE, QSType) - self.qsid = qsid - self._qsusb = qsusb.devices - dev = qsusb.devices[qsid] - self._dim = dev[QS_TYPE] == QSType.dimmer - self._name = dev[QSDATA][QS_NAME] - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the light.""" - return self._name + self.device = qsusb.devices[qsid] + super().__init__(qsid, self.device.name) @property def is_on(self): """Check if device is on (non-zero).""" - return self._qsusb[self.qsid, 1] > 0 + return self.device.value > 0 async def async_turn_on(self, **kwargs): """Turn the device on.""" new = kwargs.get(ATTR_BRIGHTNESS, 255) - self._qsusb.set_value(self.qsid, new) + self.hass.data[DOMAIN].devices.set_value(self.qsid, new) async def async_turn_off(self, **_): """Turn the device off.""" - self._qsusb.set_value(self.qsid, 0) - - def _update(self, _packet=None): - """Schedule an update - match dispather_send signature.""" - self.async_schedule_update_ha_state() - - async def async_added_to_hass(self): - """Listen for updates from QSUSb via dispatcher.""" - self.hass.helpers.dispatcher.async_dispatcher_connect( - self.qsid, self._update) + self.hass.data[DOMAIN].devices.set_value(self.qsid, 0) async def async_setup(hass, config): """Qwiskswitch component setup.""" from pyqwikswitch.async_ import QSUsb - from pyqwikswitch import ( - CMD_BUTTONS, QS_CMD, QS_ID, QS_TYPE, QSType) + from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType # Add cmd's to in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] @@ -112,8 +125,8 @@ async def async_setup(hass, config): url = config[DOMAIN][CONF_URL] dimmer_adjust = config[DOMAIN][CONF_DIMMER_ADJUST] - sensors = config[DOMAIN]['sensors'] - switches = config[DOMAIN]['switches'] + sensors = config[DOMAIN][CONF_SENSORS] + switches = config[DOMAIN][CONF_SWITCHES] def callback_value_changed(_qsd, qsid, _val): """Update entity values based on device change.""" @@ -131,17 +144,17 @@ async def async_setup(hass, config): hass.data[DOMAIN] = qsusb _new = {'switch': [], 'light': [], 'sensor': sensors} - for _id, item in qsusb.devices: - if _id in switches: - if item[QS_TYPE] != QSType.relay: + for qsid, dev in qsusb.devices.items(): + if qsid in switches: + if dev.qstype != QSType.relay: _LOGGER.warning( - "You specified a switch that is not a relay %s", _id) + "You specified a switch that is not a relay %s", qsid) continue - _new['switch'].append(_id) - elif item[QS_TYPE] in [QSType.relay, QSType.dimmer]: - _new['light'].append(_id) + _new['switch'].append(qsid) + elif dev.qstype in (QSType.relay, QSType.dimmer): + _new['light'].append(qsid) else: - _LOGGER.warning("Ignored unknown QSUSB device: %s", item) + _LOGGER.warning("Ignored unknown QSUSB device: %s", dev) continue # Load platforms @@ -149,24 +162,21 @@ async def async_setup(hass, config): if comp_conf: load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config) - def callback_qs_listen(item): + def callback_qs_listen(qspacket): """Typically a button press or update signal.""" # If button pressed, fire a hass event - if QS_ID in item: - if item.get(QS_CMD, '') in cmd_buttons: + if QS_ID in qspacket: + if qspacket.get(QS_CMD, '') in cmd_buttons: hass.bus.async_fire( - 'qwikswitch.button.{}'.format(item[QS_ID]), item) + 'qwikswitch.button.{}'.format(qspacket[QS_ID]), qspacket) return - # Private method due to bad __iter__ design in qsusb - # qsusb.devices returns a list of tuples - if item[QS_ID] not in \ - qsusb.devices._data: # pylint: disable=protected-access + if qspacket[QS_ID] not in qsusb.devices: # Not a standard device in, component can handle packet # i.e. sensors - _LOGGER.debug("Dispatch %s ((%s))", item[QS_ID], item) + _LOGGER.debug("Dispatch %s ((%s))", qspacket[QS_ID], qspacket) hass.helpers.dispatcher.async_dispatcher_send( - item[QS_ID], item) + qspacket[QS_ID], qspacket) # Update all ha_objects hass.async_add_job(qsusb.update_from_devices) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index f10e0fc75d7..64e2b85f611 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.5'] +REQUIREMENTS = ['sqlalchemy==1.2.6'] _LOGGER = logging.getLogger(__name__) @@ -47,9 +47,8 @@ ATTR_KEEP_DAYS = 'keep_days' ATTR_REPACK = 'repack' SERVICE_PURGE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_KEEP_DAYS): - vol.All(vol.Coerce(int), vol.Range(min=0)), - vol.Optional(ATTR_REPACK, default=False): cv.boolean + vol.Optional(ATTR_KEEP_DAYS): vol.All(vol.Coerce(int), vol.Range(min=0)), + vol.Optional(ATTR_REPACK, default=False): cv.boolean, }) DEFAULT_URL = 'sqlite:///{hass_config_path}' diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index e0bf3c86b05..2bc35a034f4 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -8,6 +8,8 @@ https://home-assistant.io/components/sensor/ from datetime import timedelta import logging +import voluptuous as vol + from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa @@ -18,6 +20,13 @@ DOMAIN = 'sensor' ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=30) +DEVICE_CLASSES = [ + 'battery', # % of battery that is left + 'humidity', # % of humidity in the air + 'temperature', # temperature (C/F) +] + +DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES)) async def async_setup(hass, config): diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 896497a93d5..77d8ba9322f 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -15,7 +15,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['alpha_vantage==1.9.0'] +REQUIREMENTS = ['alpha_vantage==2.0.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py index bd582da1ef4..ed75520c179 100644 --- a/homeassistant/components/sensor/bmw_connected_drive.py +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -52,6 +52,7 @@ class BMWConnectedDriveSensor(Entity): self._state = None self._unit_of_measurement = None self._name = '{} {}'.format(self._vehicle.name, self._attribute) + self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) self._sensor_name = sensor_name self._icon = icon @@ -60,6 +61,11 @@ class BMWConnectedDriveSensor(Entity): """Data update is triggered from BMWConnectedDriveEntity.""" return False + @property + def unique_id(self): + """Return the unique ID of the sensor.""" + return self._unique_id + @property def name(self) -> str: """Return the name of the sensor.""" @@ -86,7 +92,7 @@ class BMWConnectedDriveSensor(Entity): @property def device_state_attributes(self): - """Return the state attributes of the binary sensor.""" + """Return the state attributes of the sensor.""" return { 'car': self._vehicle.name } diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 044b77ebfe8..5182ba4530e 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -56,9 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) timeout = config.get(CONF_TIMEOUT) update_interval = config.get(CONF_UPDATE_INTERVAL) - broadlink_data = BroadlinkData(update_interval, host, mac_addr, timeout) - dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: dev.append(BroadlinkSensor(name, broadlink_data, variable)) @@ -104,10 +102,11 @@ class BroadlinkData(object): def __init__(self, interval, ip_addr, mac_addr, timeout): """Initialize the data object.""" - import broadlink self.data = None - self._device = broadlink.a1((ip_addr, 80), mac_addr, None) - self._device.timeout = timeout + self.ip_addr = ip_addr + self.mac_addr = mac_addr + self.timeout = timeout + self._connect() self._schema = vol.Schema({ vol.Optional('temperature'): vol.Range(min=-50, max=150), vol.Optional('humidity'): vol.Range(min=0, max=100), @@ -119,6 +118,11 @@ class BroadlinkData(object): if not self._auth(): _LOGGER.warning("Failed to connect to device") + def _connect(self): + import broadlink + self._device = broadlink.a1((self.ip_addr, 80), self.mac_addr, None) + self._device.timeout = self.timeout + def _update(self, retry=3): try: data = self._device.check_sensors_raw() @@ -140,5 +144,6 @@ class BroadlinkData(object): except socket.timeout: auth = False if not auth and retry > 0: + self._connect() return self._auth(retry-1) return auth diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 081b304dc55..e569c5578ac 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -16,6 +16,7 @@ from homeassistant.util import slugify DEPENDENCIES = ['deconz'] ATTR_CURRENT = 'current' +ATTR_DAYLIGHT = 'daylight' ATTR_EVENT_ID = 'event_id' @@ -113,6 +114,8 @@ class DeconzSensor(Entity): if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._sensor.current attr[ATTR_VOLTAGE] = self._sensor.voltage + if self._sensor.sensor_class == 'daylight': + attr[ATTR_DAYLIGHT] = self._sensor.daylight return attr diff --git a/homeassistant/components/sensor/ebox.py b/homeassistant/components/sensor/ebox.py index eee959fceba..aca2d7bdb9a 100644 --- a/homeassistant/components/sensor/ebox.py +++ b/homeassistant/components/sensor/ebox.py @@ -19,7 +19,8 @@ from homeassistant.const import ( CONF_NAME, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyebox==0.1.0'] +# pylint: disable=import-error +REQUIREMENTS = [] # ['pyebox==0.1.0'] - disabled because it breaks pip10 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/ecobee.py b/homeassistant/components/sensor/ecobee.py index dad770d5bab..7274f421f15 100644 --- a/homeassistant/components/sensor/ecobee.py +++ b/homeassistant/components/sensor/ecobee.py @@ -52,6 +52,13 @@ class EcobeeSensor(Entity): """Return the name of the Ecobee sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + if self.type in ('temperature', 'humidity'): + return self.type + return None + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index 06accb26eb6..2c8ad4781d0 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) -REQUIREMENTS = ['beacontools[scan]==1.2.1', 'construct==2.9.41'] +REQUIREMENTS = ['beacontools[scan]==1.2.3', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 3faf51a5f47..5b28faf78ca 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -8,6 +8,9 @@ import logging import statistics from collections import deque, Counter from numbers import Number +from functools import partial +from copy import copy +from datetime import timedelta import voluptuous as vol @@ -20,6 +23,7 @@ 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 +import homeassistant.components.history as history import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -40,6 +44,9 @@ CONF_TIME_SMA_TYPE = 'type' TIME_SMA_LAST = 'last' +WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1 +WINDOW_SIZE_UNIT_TIME = 2 + DEFAULT_WINDOW_SIZE = 1 DEFAULT_PRECISION = 2 DEFAULT_FILTER_RADIUS = 2.0 @@ -123,21 +130,22 @@ class SensorFilter(Entity): async def async_added_to_hass(self): """Register callbacks.""" @callback - def filter_sensor_state_listener(entity, old_state, new_state): + def filter_sensor_state_listener(entity, old_state, new_state, + update_ha=True): """Handle device state changes.""" if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return - temp_state = new_state.state + temp_state = new_state try: for filt in self._filters: - filtered_state = filt.filter_state(temp_state) + filtered_state = filt.filter_state(copy(temp_state)) _LOGGER.debug("%s(%s=%s) -> %s", filt.name, self._entity, - temp_state, + temp_state.state, "skip" if filt.skip_processing else - filtered_state) + filtered_state.state) if filt.skip_processing: return temp_state = filtered_state @@ -146,7 +154,7 @@ class SensorFilter(Entity): self._state) return - self._state = temp_state + self._state = temp_state.state if self._icon is None: self._icon = new_state.attributes.get( @@ -156,7 +164,50 @@ class SensorFilter(Entity): self._unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT) - self.async_schedule_update_ha_state() + if update_ha: + self.async_schedule_update_ha_state() + + if 'recorder' in self.hass.config.components: + history_list = [] + largest_window_items = 0 + largest_window_time = timedelta(0) + + # Determine the largest window_size by type + for filt in self._filters: + if filt.window_unit == WINDOW_SIZE_UNIT_NUMBER_EVENTS\ + and largest_window_items < filt.window_size: + largest_window_items = filt.window_size + elif filt.window_unit == WINDOW_SIZE_UNIT_TIME\ + and largest_window_time < filt.window_size: + largest_window_time = filt.window_size + + # Retrieve the largest window_size of each type + if largest_window_items > 0: + filter_history = await self.hass.async_add_job(partial( + history.get_last_state_changes, self.hass, + largest_window_items, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity]]) + if largest_window_time > timedelta(seconds=0): + start = dt_util.utcnow() - largest_window_time + filter_history = await self.hass.async_add_job(partial( + history.state_changes_during_period, self.hass, + start, entity_id=self._entity)) + history_list.extend( + [state for state in filter_history[self._entity] + if state not in history_list]) + + # Sort the window states + history_list = sorted(history_list, key=lambda s: s.last_updated) + _LOGGER.debug("Loading from history: %s", + [(s.state, s.last_updated) for s in history_list]) + + # Replay history through the filter chain + prev_state = None + for state in history_list: + filter_sensor_state_listener( + self._entity, prev_state, state, False) + prev_state = state async_track_state_change( self.hass, self._entity, filter_sensor_state_listener) @@ -195,6 +246,31 @@ class SensorFilter(Entity): return state_attr +class FilterState(object): + """State abstraction for filter usage.""" + + def __init__(self, state): + """Initialize with HA State object.""" + self.timestamp = state.last_updated + try: + self.state = float(state.state) + except ValueError: + self.state = state.state + + def set_precision(self, precision): + """Set precision of Number based states.""" + if isinstance(self.state, Number): + self.state = round(float(self.state), precision) + + def __str__(self): + """Return state as the string representation of FilterState.""" + return str(self.state) + + def __repr__(self): + """Return timestamp and state as the representation of FilterState.""" + return "{} : {}".format(self.timestamp, self.state) + + class Filter(object): """Filter skeleton. @@ -207,11 +283,22 @@ class Filter(object): def __init__(self, name, window_size=1, precision=None, entity=None): """Initialize common attributes.""" - self.states = deque(maxlen=window_size) + if isinstance(window_size, int): + self.states = deque(maxlen=window_size) + self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS + else: + self.states = deque(maxlen=0) + self.window_unit = WINDOW_SIZE_UNIT_TIME self.precision = precision self._name = name self._entity = entity self._skip_processing = False + self._window_size = window_size + + @property + def window_size(self): + """Return window size.""" + return self._window_size @property def name(self): @@ -229,11 +316,11 @@ class Filter(object): 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 + filtered = self._filter_state(FilterState(new_state)) + filtered.set_precision(self.precision) + self.states.append(copy(filtered)) + new_state.state = filtered.state + return new_state @FILTERS.register(FILTER_NAME_OUTLIER) @@ -254,11 +341,10 @@ class OutlierFilter(Filter): 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): + if (len(self.states) == self.states.maxlen and + abs(new_state.state - + statistics.median([s.state for s in self.states])) > + self._radius): self._stats_internal['erasures'] += 1 @@ -284,16 +370,15 @@ class LowPassFilter(Filter): 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 + new_state.state = prev_weight * self.states[-1].state +\ + new_weight * new_state.state - return filtered + return new_state @FILTERS.register(FILTER_NAME_TIME_SMA) @@ -308,35 +393,36 @@ class TimeSMAFilter(Filter): def __init__(self, window_size, precision, entity, type): """Initialize Filter.""" - super().__init__(FILTER_NAME_TIME_SMA, 0, precision, entity) - self._time_window = int(window_size.total_seconds()) + super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity) + self._time_window = window_size self.last_leak = None self.queue = deque() - def _leak(self, now): + def _leak(self, left_boundary): """Remove timeouted elements.""" while self.queue: - timestamp, _ = self.queue[0] - if timestamp + self._time_window <= now: + if self.queue[0].timestamp + self._time_window <= left_boundary: self.last_leak = self.queue.popleft() else: return def _filter_state(self, new_state): - now = int(dt_util.utcnow().timestamp()) + """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) + self.queue.append(copy(new_state)) - self._leak(now) - self.queue.append((now, float(new_state))) moving_sum = 0 - start = now - self._time_window - _, prev_val = self.last_leak or (0, float(new_state)) + start = new_state.timestamp - self._time_window + prev_state = self.last_leak or self.queue[0] + for state in self.queue: + moving_sum += (state.timestamp-start).total_seconds()\ + * prev_state.state + start = state.timestamp + prev_state = state - for timestamp, val in self.queue: - moving_sum += (timestamp-start)*prev_val - start, prev_val = timestamp, val - moving_sum += (now-start)*prev_val + new_state.state = moving_sum / self._time_window.total_seconds() - return moving_sum/self._time_window + return new_state @FILTERS.register(FILTER_NAME_THROTTLE) diff --git a/homeassistant/components/sensor/folder.py b/homeassistant/components/sensor/folder.py index a185cd1e825..2b5f3dd4309 100644 --- a/homeassistant/components/sensor/folder.py +++ b/homeassistant/components/sensor/folder.py @@ -38,7 +38,7 @@ def get_files_list(folder_path, filter_term): def get_size(files_list): """Return the sum of the size in bytes of files in the list.""" - size_list = [os.stat(f).st_size for f in files_list] + size_list = [os.stat(f).st_size for f in files_list if os.path.isfile(f)] return sum(size_list) diff --git a/homeassistant/components/sensor/foobot.py b/homeassistant/components/sensor/foobot.py index 8f65a335872..d247a90e93a 100644 --- a/homeassistant/components/sensor/foobot.py +++ b/homeassistant/components/sensor/foobot.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['foobot_async==0.3.0'] +REQUIREMENTS = ['foobot_async==0.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index f4f774cad1e..857e6cc4a07 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -11,7 +11,7 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_HOST, STATE_UNAVAILABLE) +from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_UNAVAILABLE) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -20,6 +20,7 @@ REQUIREMENTS = ['fritzconnection==0.6.5'] _LOGGER = logging.getLogger(__name__) +CONF_DEFAULT_NAME = 'fritz_netmonitor' CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers. ATTR_BYTES_RECEIVED = 'bytes_received' @@ -42,6 +43,7 @@ STATE_OFFLINE = 'offline' ICON = 'mdi:web' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, }) @@ -52,6 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import fritzconnection as fc from fritzconnection.fritzconnection import FritzConnectionException + name = config.get(CONF_NAME) host = config.get(CONF_HOST) try: @@ -65,15 +68,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: _LOGGER.info("Successfully connected to FRITZ!Box") - add_devices([FritzboxMonitorSensor(fstatus)], True) + add_devices([FritzboxMonitorSensor(name, fstatus)], True) class FritzboxMonitorSensor(Entity): """Implementation of a fritzbox monitor sensor.""" - def __init__(self, fstatus): + def __init__(self, name, fstatus): """Initialize the sensor.""" - self._name = 'fritz_netmonitor' + self._name = name self._fstatus = fstatus self._state = STATE_UNAVAILABLE self._is_linked = self._is_connected = self._wan_access_type = None diff --git a/homeassistant/components/sensor/hive.py b/homeassistant/components/sensor/hive.py index cae2eaf7437..8f8ce2d1681 100644 --- a/homeassistant/components/sensor/hive.py +++ b/homeassistant/components/sensor/hive.py @@ -4,11 +4,17 @@ Support for the Hive devices. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.hive/ """ +from homeassistant.const import TEMP_CELSIUS from homeassistant.components.hive import DATA_HIVE from homeassistant.helpers.entity import Entity DEPENDENCIES = ['hive'] +FRIENDLY_NAMES = {'Hub_OnlineStatus': 'Hub Status', + 'Hive_OutsideTemperature': 'Outside Temperature'} +DEVICETYPE_ICONS = {'Hub_OnlineStatus': 'mdi:switch', + 'Hive_OutsideTemperature': 'mdi:thermometer'} + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Hive sensor devices.""" @@ -16,7 +22,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return session = hass.data.get(DATA_HIVE) - if discovery_info["HA_DeviceType"] == "Hub_OnlineStatus": + if (discovery_info["HA_DeviceType"] == "Hub_OnlineStatus" or + discovery_info["HA_DeviceType"] == "Hive_OutsideTemperature"): add_devices([HiveSensorEntity(session, discovery_info)]) @@ -27,6 +34,7 @@ class HiveSensorEntity(Entity): """Initialize the sensor.""" self.node_id = hivedevice["Hive_NodeID"] self.device_type = hivedevice["HA_DeviceType"] + self.node_device_type = hivedevice["Hive_DeviceType"] self.session = hivesession self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) @@ -40,13 +48,29 @@ class HiveSensorEntity(Entity): @property def name(self): """Return the name of the sensor.""" - return "Hive hub status" + return FRIENDLY_NAMES.get(self.device_type) @property def state(self): """Return the state of the sensor.""" - return self.session.sensor.hub_online_status(self.node_id) + if self.device_type == "Hub_OnlineStatus": + return self.session.sensor.hub_online_status(self.node_id) + elif self.device_type == "Hive_OutsideTemperature": + return self.session.weather.temperature() + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + if self.device_type == "Hive_OutsideTemperature": + return TEMP_CELSIUS + + @property + def icon(self): + """Return the icon to use.""" + return DEVICETYPE_ICONS.get(self.device_type) def update(self): - """Update all Node data from Hive.""" - self.session.core.update_data(self.node_id) + """Update all Node data frome Hive.""" + if self.session.core.update_data(self.node_id): + for entity in self.session.entities: + entity.handle_update(self.data_updatesource) diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index 3d28c44d606..1f0e3e89e5c 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -94,6 +94,11 @@ class LinuxBatterySensor(Entity): """Return the name of the sensor.""" return self._name + @property + def device_class(self): + """Return the device class of the sensor.""" + return 'battery' + @property def state(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index 37976151190..98cc7731d4d 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -16,7 +16,7 @@ from homeassistant.const import ( ) -REQUIREMENTS = ['miflora==0.3.0'] +REQUIREMENTS = ['miflora==0.4.0'] _LOGGER = logging.getLogger(__name__) @@ -63,10 +63,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): from miflora import miflora_poller try: import bluepy.btle # noqa: F401 # pylint: disable=unused-variable - from miflora.backends.bluepy import BluepyBackend + from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: - from miflora.backends.gatttool import GatttoolBackend + from btlewrap import GatttoolBackend backend = GatttoolBackend _LOGGER.debug('Miflora is using %s backend.', backend.__name__) @@ -138,7 +138,7 @@ class MiFloraSensor(Entity): This uses a rolling median over 3 values to filter out outliers. """ - from miflora.backends import BluetoothBackendException + from btlewrap import BluetoothBackendException try: _LOGGER.debug("Polling data for %s", self.name) data = self.poller.parameter_value(self.parameter) diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index d191b9a22e8..c4f64e9e015 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -8,6 +8,7 @@ import asyncio import logging import json from datetime import timedelta +from typing import Optional import voluptuous as vol @@ -28,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) CONF_EXPIRE_AFTER = 'expire_after' CONF_JSON_ATTRS = 'json_attributes' +CONF_UNIQUE_ID = 'unique_id' DEFAULT_NAME = 'MQTT Sensor' DEFAULT_FORCE_UPDATE = False @@ -40,6 +42,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + # Integrations shouldn't never expose unique_id through configuration + # this here is an exception because MQTT is a msg transport, not a protocol + vol.Optional(CONF_UNIQUE_ID): cv.string, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -63,6 +68,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_ICON), value_template, config.get(CONF_JSON_ATTRS), + config.get(CONF_UNIQUE_ID), config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), @@ -74,7 +80,8 @@ class MqttSensor(MqttAvailability, Entity): def __init__(self, name, state_topic, qos, unit_of_measurement, force_update, expire_after, icon, value_template, - json_attributes, availability_topic, payload_available, + json_attributes, unique_id: Optional[str], + availability_topic, payload_available, payload_not_available): """Initialize the sensor.""" super().__init__(availability_topic, qos, payload_available, @@ -90,6 +97,7 @@ class MqttSensor(MqttAvailability, Entity): self._icon = icon self._expiration_trigger = None self._json_attributes = set(json_attributes) + self._unique_id = unique_id self._attributes = None @asyncio.coroutine @@ -174,6 +182,11 @@ class MqttSensor(MqttAvailability, Entity): """Return the state attributes.""" return self._attributes + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def icon(self): """Return the icon.""" diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index e2567fdf4ca..5ee4f738051 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -140,6 +140,11 @@ class NestTempSensor(NestSensor): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the device class of the sensor.""" + return 'temperature' + def update(self): """Retrieve latest state.""" if self.device.temperature_scale == 'C': diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index e0d5b7250e9..b8917080efc 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -113,6 +113,7 @@ STATE_TYPES = { 'HB': 'High Battery', 'RB': 'Battery Needs Replaced', 'CHRG': 'Battery Charging', + 'DISCHRG': 'Battery Discharging', 'BYPASS': 'Bypass Active', 'CAL': 'Runtime Calibration', 'OFF': 'Offline', diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py index 19b32e93670..ebd5f5254d4 100644 --- a/homeassistant/components/sensor/qwikswitch.py +++ b/homeassistant/components/sensor/qwikswitch.py @@ -6,8 +6,8 @@ https://home-assistant.io/components/sensor.qwikswitch/ """ import logging -from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH -from homeassistant.helpers.entity import Entity +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH, QSEntity +from homeassistant.core import callback DEPENDENCIES = [QWIKSWITCH] @@ -15,55 +15,54 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, _, add_devices, discovery_info=None): - """Add lights from the main Qwikswitch component.""" + """Add sensor from the main Qwikswitch component.""" if discovery_info is None: return qsusb = hass.data[QWIKSWITCH] _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info) - devs = [QSSensor(name, qsid) - for name, qsid in discovery_info[QWIKSWITCH].items()] + devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]] add_devices(devs) -class QSSensor(Entity): +class QSSensor(QSEntity): """Sensor based on a Qwikswitch relay/dimmer module.""" - _val = {} + _val = None - def __init__(self, sensor_name, sensor_id): + def __init__(self, sensor): """Initialize the sensor.""" - self._name = sensor_name - self.qsid = sensor_id + from pyqwikswitch import SENSORS + super().__init__(sensor['id'], sensor['name']) + self.channel = sensor['channel'] + self.sensor_type = sensor['type'] + + self._decode, self.unit = SENSORS[self.sensor_type] + if isinstance(self.unit, type): + self.unit = "{}:{}".format(self.sensor_type, self.channel) + + @callback def update_packet(self, packet): """Receive update packet from QSUSB.""" - _LOGGER.debug("Update %s (%s): %s", self.entity_id, self.qsid, packet) - self._val = packet - self.async_schedule_update_ha_state() + val = self._decode(packet.get('data'), channel=self.channel) + _LOGGER.debug("Update %s (%s) decoded as %s: %s: %s", + self.entity_id, self.qsid, val, self.channel, packet) + if val is not None: + self._val = val + self.async_schedule_update_ha_state() @property def state(self): """Return the value of the sensor.""" - return self._val.get('data', 0) + return str(self._val) @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - return self._val + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}:{}".format(self.qsid, self.channel) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return None - - @property - def poll(self): - """QS sensors gets packets in update_packet.""" - return False - - async def async_added_to_hass(self): - """Listen for updates from QSUSb via dispatcher.""" - # Part of Entity/ToggleEntity - self.hass.helpers.dispatcher.async_dispatcher_connect( - self.qsid, self.update_packet) + return self.unit diff --git a/homeassistant/components/sensor/sht31.py b/homeassistant/components/sensor/sht31.py new file mode 100644 index 00000000000..e1a7f3c9e5f --- /dev/null +++ b/homeassistant/components/sensor/sht31.py @@ -0,0 +1,152 @@ +""" +Support for Sensirion SHT31 temperature and humidity sensor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sht31/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + TEMP_CELSIUS, CONF_NAME, CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.temperature import display_temp +from homeassistant.const import PRECISION_TENTHS +from homeassistant.util import Throttle + + +REQUIREMENTS = ['Adafruit-GPIO==1.0.3', + 'Adafruit-SHT31==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_I2C_ADDRESS = 'i2c_address' + +DEFAULT_NAME = 'SHT31' +DEFAULT_I2C_ADDRESS = 0x44 + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' +SENSOR_TYPES = (SENSOR_TEMPERATURE, SENSOR_HUMIDITY) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): + vol.All(vol.Coerce(int), vol.Range(min=0x44, max=0x45)), + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the sensor platform.""" + from Adafruit_SHT31 import SHT31 + + i2c_address = config.get(CONF_I2C_ADDRESS) + sensor = SHT31(address=i2c_address) + + try: + if sensor.read_status() is None: + raise ValueError("CRC error while reading SHT31 status") + except (OSError, ValueError): + _LOGGER.error( + "SHT31 sensor not detected at address %s", hex(i2c_address)) + return + sensor_client = SHTClient(sensor) + + sensor_classes = { + SENSOR_TEMPERATURE: SHTSensorTemperature, + SENSOR_HUMIDITY: SHTSensorHumidity + } + + devs = [] + for sensor_type, sensor_class in sensor_classes.items(): + name = "{} {}".format(config.get(CONF_NAME), sensor_type.capitalize()) + devs.append(sensor_class(sensor_client, name)) + + add_devices(devs) + + +class SHTClient(object): + """Get the latest data from the SHT sensor.""" + + def __init__(self, adafruit_sht): + """Initialize the sensor.""" + self.adafruit_sht = adafruit_sht + self.temperature = None + self.humidity = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from the SHT sensor.""" + temperature, humidity = self.adafruit_sht.read_temperature_humidity() + if math.isnan(temperature) or math.isnan(humidity): + _LOGGER.warning("Bad sample from sensor SHT31") + return + self.temperature = temperature + self.humidity = humidity + + +class SHTSensor(Entity): + """An abstract SHTSensor, can be either temperature or humidity.""" + + def __init__(self, sensor, name): + """Initialize the sensor.""" + self._sensor = sensor + self._name = name + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Fetch temperature and humidity from the sensor.""" + self._sensor.update() + + +class SHTSensorTemperature(SHTSensor): + """Representation of a temperature sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + def update(self): + """Fetch temperature from the sensor.""" + super().update() + temp_celsius = self._sensor.temperature + if temp_celsius is not None: + self._state = display_temp(self.hass, temp_celsius, + TEMP_CELSIUS, PRECISION_TENTHS) + + +class SHTSensorHumidity(SHTSensor): + """Representation of a humidity sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return '%' + + def update(self): + """Fetch humidity from the sensor.""" + super().update() + humidity = self._sensor.humidity + if humidity is not None: + self._state = round(humidity) diff --git a/homeassistant/components/sensor/sigfox.py b/homeassistant/components/sensor/sigfox.py new file mode 100644 index 00000000000..ef47132eefc --- /dev/null +++ b/homeassistant/components/sensor/sigfox.py @@ -0,0 +1,161 @@ +""" +Sensor for SigFox devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.sigfox/ +""" +import logging +import datetime +import json +from urllib.parse import urljoin + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=30) +API_URL = 'https://backend.sigfox.com/api/' +CONF_API_LOGIN = 'api_login' +CONF_API_PASSWORD = 'api_password' +DEFAULT_NAME = 'sigfox' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_LOGIN): cv.string, + vol.Required(CONF_API_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sigfox sensor.""" + api_login = config[CONF_API_LOGIN] + api_password = config[CONF_API_PASSWORD] + name = config[CONF_NAME] + try: + sigfox = SigfoxAPI(api_login, api_password) + except ValueError: + return False + auth = sigfox.auth + devices = sigfox.devices + + sensors = [] + for device in devices: + sensors.append(SigfoxDevice(device, auth, name)) + add_devices(sensors, True) + + +def epoch_to_datetime(epoch_time): + """Take an ms since epoch and return datetime string.""" + return datetime.datetime.fromtimestamp(epoch_time).isoformat() + + +class SigfoxAPI(object): + """Class for interacting with the SigFox API.""" + + def __init__(self, api_login, api_password): + """Initialise the API object.""" + self._auth = requests.auth.HTTPBasicAuth(api_login, api_password) + if self.check_credentials(): + device_types = self.get_device_types() + self._devices = self.get_devices(device_types) + + def check_credentials(self): + """"Check API credentials are valid.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + if response.status_code != 200: + if response.status_code == 401: + _LOGGER.error( + "Invalid credentials for Sigfox API") + else: + _LOGGER.error( + "Unable to login to Sigfox API, error code %s", str( + response.status_code)) + raise ValueError('Sigfox component not setup') + return True + + def get_device_types(self): + """Get a list of device types.""" + url = urljoin(API_URL, 'devicetypes') + response = requests.get(url, auth=self._auth, timeout=10) + device_types = [] + for device in json.loads(response.text)['data']: + device_types.append(device['id']) + return device_types + + def get_devices(self, device_types): + """Get the device_id of each device registered.""" + devices = [] + for unique_type in device_types: + location_url = 'devicetypes/{}/devices'.format(unique_type) + url = urljoin(API_URL, location_url) + response = requests.get(url, auth=self._auth, timeout=10) + devices_data = json.loads(response.text)['data'] + for device in devices_data: + devices.append(device['id']) + return devices + + @property + def auth(self): + """Return the API authentification.""" + return self._auth + + @property + def devices(self): + """Return the list of device_id.""" + return self._devices + + +class SigfoxDevice(Entity): + """Class for single sigfox device.""" + + def __init__(self, device_id, auth, name): + """Initialise the device object.""" + self._device_id = device_id + self._auth = auth + self._message_data = {} + self._name = '{}_{}'.format(name, device_id) + self._state = None + + def get_last_message(self): + """Return the last message from a device.""" + device_url = 'devices/{}/messages?limit=1'.format(self._device_id) + url = urljoin(API_URL, device_url) + response = requests.get(url, auth=self._auth, timeout=10) + data = json.loads(response.text)['data'][0] + payload = bytes.fromhex(data['data']).decode('utf-8') + lat = data['rinfos'][0]['lat'] + lng = data['rinfos'][0]['lng'] + snr = data['snr'] + epoch_time = data['time'] + return {'lat': lat, + 'lng': lng, + 'payload': payload, + 'snr': snr, + 'time': epoch_to_datetime(epoch_time)} + + def update(self): + """Fetch the latest device message.""" + self._message_data = self.get_last_message() + self._state = self._message_data['payload'] + + @property + def name(self): + """Return the HA name of the sensor.""" + return self._name + + @property + def state(self): + """Return the payload of the last message.""" + return self._state + + @property + def device_state_attributes(self): + """Return other details about the last message.""" + return self._message_data diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py index c59798d16d7..5b84962144d 100644 --- a/homeassistant/components/sensor/smappee.py +++ b/homeassistant/components/sensor/smappee.py @@ -31,7 +31,19 @@ SENSOR_TYPES = { 'solar_today': ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kWh', 'solar'], 'power_today': - ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'] + ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'], + 'water_sensor_1': + ['Water Sensor 1', 'mdi:water', 'water', 'm3', 'value1'], + 'water_sensor_2': + ['Water Sensor 2', 'mdi:water', 'water', 'm3', 'value2'], + 'water_sensor_temperature': + ['Water Sensor Temperature', 'mdi:temperature-celsius', + 'water', '°', 'temperature'], + 'water_sensor_humidity': + ['Water Sensor Humidity', 'mdi:water-percent', 'water', + '%', 'humidity'], + 'water_sensor_battery': + ['Water Sensor Battery', 'mdi:battery', 'water', '%', 'battery'], } SCAN_INTERVAL = timedelta(seconds=30) @@ -43,36 +55,50 @@ def setup_platform(hass, config, add_devices, discovery_info=None): dev = [] if smappee.is_remote_active: - for sensor in SENSOR_TYPES: - if 'remote' in SENSOR_TYPES[sensor]: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'remote' in SENSOR_TYPES[sensor]: + dev.append(SmappeeSensor(smappee, location_id, + sensor, + SENSOR_TYPES[sensor])) + elif 'water' in SENSOR_TYPES[sensor]: + for items in smappee.info[location_id].get('sensors'): + dev.append(SmappeeSensor( + smappee, + location_id, + '{}:{}'.format(sensor, items.get('id')), + SENSOR_TYPES[sensor])) if smappee.is_local_active: - for sensor in SENSOR_TYPES: - if 'local' in SENSOR_TYPES[sensor]: - if smappee.is_remote_active: - for location_id in smappee.locations.keys(): - dev.append(SmappeeSensor(smappee, location_id, sensor)) - else: - dev.append(SmappeeSensor(smappee, None, sensor)) + for location_id in smappee.locations.keys(): + for sensor in SENSOR_TYPES: + if 'local' in SENSOR_TYPES[sensor]: + if smappee.is_remote_active: + dev.append(SmappeeSensor(smappee, location_id, sensor, + SENSOR_TYPES[sensor])) + else: + dev.append(SmappeeSensor(smappee, None, sensor, + SENSOR_TYPES[sensor])) + add_devices(dev, True) class SmappeeSensor(Entity): """Implementation of a Smappee sensor.""" - def __init__(self, smappee, location_id, sensor): - """Initialize the sensor.""" + def __init__(self, smappee, location_id, sensor, attributes): + """Initialize the Smappee sensor.""" self._smappee = smappee self._location_id = location_id + self._attributes = attributes self._sensor = sensor self.data = None self._state = None - self._name = SENSOR_TYPES[self._sensor][0] - self._icon = SENSOR_TYPES[self._sensor][1] - self._unit_of_measurement = SENSOR_TYPES[self._sensor][3] - self._smappe_name = SENSOR_TYPES[self._sensor][4] + self._name = self._attributes[0] + self._icon = self._attributes[1] + self._type = self._attributes[2] + self._unit_of_measurement = self._attributes[3] + self._smappe_name = self._attributes[4] @property def name(self): @@ -82,9 +108,7 @@ class SmappeeSensor(Entity): else: location_name = 'Local' - return "{} {} {}".format(SENSOR_PREFIX, - location_name, - self._name) + return "{} {} {}".format(SENSOR_PREFIX, location_name, self._name) @property def icon(self): @@ -160,3 +184,13 @@ class SmappeeSensor(Entity): if i['key'].endswith('phase5ActivePower')] power = sum(value1 + value2 + value3) / 1000 self._state = round(power, 2) + elif self._type == 'water': + sensor_name, sensor_id = self._sensor.split(":") + data = self._smappee.sensor_consumption[self._location_id]\ + .get(int(sensor_id)) + if data: + consumption = data.get('records')[-1] + _LOGGER.debug("%s (%s) %s", + sensor_name, sensor_id, consumption) + value = consumption.get(self._smappe_name) + self._state = value diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index af9fa233d40..eeca31fa36b 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.5'] +REQUIREMENTS = ['sqlalchemy==1.2.6'] CONF_QUERIES = 'queries' CONF_QUERY = 'query' diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index aaaa8366909..4fb378ac227 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -19,8 +19,9 @@ 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 +from homeassistant.util import Throttle -REQUIREMENTS = ['pyTibber==0.4.0'] +REQUIREMENTS = ['pyTibber==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -30,6 +31,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=1) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) async def async_setup_platform(hass, config, async_add_devices, @@ -58,7 +60,9 @@ class TibberSensor(Entity): """Initialize the sensor.""" self._tibber_home = tibber_home self._last_updated = None + self._newest_data_timestamp = None self._state = None + self._is_available = False self._device_state_attributes = {} self._unit_of_measurement = self._tibber_home.price_unit self._name = 'Electricity price {}'.format(tibber_home.info['viewer'] @@ -68,50 +72,27 @@ class TibberSensor(Entity): """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\ - == now.hour: + self._last_updated.hour == now.hour and self._newest_data_timestamp: return - def _find_current_price(): - state = None - max_price = None - min_price = None - for key, price_total in self._tibber_home.price_total.items(): - price_time = dt_util.as_utc(dt_util.parse_datetime(key)) - price_total = round(price_total, 3) - time_diff = (now - price_time).total_seconds()/60 - if time_diff >= 0 and time_diff < 60: - state = price_total - self._last_updated = key - if now.date() == price_time.date(): - if max_price is None or price_total > max_price: - max_price = price_total - if min_price is None or price_total < min_price: - min_price = price_total - self._state = state - self._device_state_attributes['max_price'] = max_price - self._device_state_attributes['min_price'] = min_price - return state is not None + if (not self._newest_data_timestamp or + (self._newest_data_timestamp - now).total_seconds()/3600 < 12 + or not self._is_available): + _LOGGER.debug("Asking for new data.") + await self._fetch_data() - 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() + self._is_available = self._update_current_price() @property def device_state_attributes(self): """Return the state attributes.""" return self._device_state_attributes + @property + def available(self): + """Return True if entity is available.""" + return self._is_available + @property def name(self): """Return the name of the sensor.""" @@ -137,3 +118,42 @@ class TibberSensor(Entity): """Return a unique ID.""" home = self._tibber_home.info['viewer']['home'] return home['meteringPointData']['consumptionEan'] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def _fetch_data(self): + try: + await self._tibber_home.update_info() + await self._tibber_home.update_price_info() + except (asyncio.TimeoutError, aiohttp.ClientError): + return + 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'] + + def _update_current_price(self): + state = None + max_price = None + min_price = None + now = dt_util.utcnow() + for key, price_total in self._tibber_home.price_total.items(): + price_time = dt_util.as_utc(dt_util.parse_datetime(key)) + price_total = round(price_total, 3) + time_diff = (now - price_time).total_seconds()/60 + if (not self._newest_data_timestamp or + price_time > self._newest_data_timestamp): + self._newest_data_timestamp = price_time + if 0 <= time_diff < 60: + state = price_total + self._last_updated = price_time + if now.date() == price_time.date(): + if max_price is None or price_total > max_price: + max_price = price_total + if min_price is None or price_total < min_price: + min_price = price_total + self._state = state + self._device_state_attributes['max_price'] = max_price + self._device_state_attributes['min_price'] = min_price + return state is not None diff --git a/homeassistant/components/sensor/trafikverket_weatherstation.py b/homeassistant/components/sensor/trafikverket_weatherstation.py index fba16c27c7e..77a2b0e7338 100644 --- a/homeassistant/components/sensor/trafikverket_weatherstation.py +++ b/homeassistant/components/sensor/trafikverket_weatherstation.py @@ -4,17 +4,17 @@ Weather information for air and road temperature, provided by Trafikverket. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.trafikverket_weatherstation/ """ +from datetime import timedelta import json import logging -from datetime import timedelta import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_API_KEY, CONF_TYPE) + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -25,6 +25,7 @@ CONF_ATTRIBUTION = "Data provided by Trafikverket API" CONF_STATION = 'station' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + SCAN_INTERVAL = timedelta(seconds=300) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -36,7 +37,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): - """Setup the sensor platform.""" + """Set up the Trafikverket sensor platform.""" sensor_name = config.get(CONF_NAME) sensor_api = config.get(CONF_API_KEY) sensor_station = config.get(CONF_STATION) @@ -47,10 +48,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TrafikverketWeatherStation(Entity): - """Representation of a Sensor.""" + """Representation of a Trafikverket sensor.""" def __init__(self, sensor_name, sensor_api, sensor_station, sensor_type): - """Initialize the sensor.""" + """Initialize the Trafikverket sensor.""" self._name = sensor_name self._api = sensor_api self._station = sensor_station @@ -82,10 +83,7 @@ class TrafikverketWeatherStation(Entity): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ + """Fetch new state data for the sensor.""" url = 'http://api.trafikinfo.trafikverket.se/v1.3/data.json' if self._type == 'road': @@ -117,7 +115,7 @@ class TrafikverketWeatherStation(Entity): result = data["RESPONSE"]["RESULT"][0] final = result["WeatherStation"][0]["Measurement"] except KeyError: - _LOGGER.error("Incorrect weather station or API key.") + _LOGGER.error("Incorrect weather station or API key") return # air_vs_road contains "Air" or "Road" depending on user input. diff --git a/homeassistant/components/sensor/upnp.py b/homeassistant/components/sensor/upnp.py index e5acae67916..e0c57ca9ac6 100644 --- a/homeassistant/components/sensor/upnp.py +++ b/homeassistant/components/sensor/upnp.py @@ -6,38 +6,44 @@ https://home-assistant.io/components/sensor.upnp/ """ import logging -from homeassistant.components.upnp import DATA_UPNP, UNITS +from homeassistant.components.upnp import DATA_UPNP, UNITS, CIC_SERVICE from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) +BYTES_RECEIVED = 1 +BYTES_SENT = 2 +PACKETS_RECEIVED = 3 +PACKETS_SENT = 4 + # sensor_type: [friendly_name, convert_unit, icon] SENSOR_TYPES = { - 'byte_received': ['received bytes', True, 'mdi:server-network'], - 'byte_sent': ['sent bytes', True, 'mdi:server-network'], - 'packets_in': ['packets received', False, 'mdi:server-network'], - 'packets_out': ['packets sent', False, 'mdi:server-network'], + BYTES_RECEIVED: ['received bytes', True, 'mdi:server-network'], + BYTES_SENT: ['sent bytes', True, 'mdi:server-network'], + PACKETS_RECEIVED: ['packets received', False, 'mdi:server-network'], + PACKETS_SENT: ['packets sent', False, 'mdi:server-network'], } -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, add_devices, discovery_info=None): """Set up the IGD sensors.""" - upnp = hass.data[DATA_UPNP] + device = hass.data[DATA_UPNP] + service = device.find_first_service(CIC_SERVICE) unit = discovery_info['unit'] add_devices([ - IGDSensor(upnp, t, unit if SENSOR_TYPES[t][1] else None) + IGDSensor(service, t, unit if SENSOR_TYPES[t][1] else '#') for t in SENSOR_TYPES], True) class IGDSensor(Entity): """Representation of a UPnP IGD sensor.""" - def __init__(self, upnp, sensor_type, unit=""): + def __init__(self, service, sensor_type, unit=None): """Initialize the IGD sensor.""" - self._upnp = upnp + self._service = service self.type = sensor_type self.unit = unit - self.unit_factor = UNITS[unit] if unit is not None else 1 + self.unit_factor = UNITS[unit] if unit in UNITS else 1 self._name = 'IGD {}'.format(SENSOR_TYPES[sensor_type][0]) self._state = None @@ -49,9 +55,9 @@ class IGDSensor(Entity): @property def state(self): """Return the state of the device.""" - if self._state is None: - return None - return format(self._state / self.unit_factor, '.1f') + if self._state: + return format(float(self._state) / self.unit_factor, '.1f') + return self._state @property def icon(self): @@ -63,13 +69,13 @@ class IGDSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self.unit - def update(self): + async def async_update(self): """Get the latest information from the IGD.""" - if self.type == "byte_received": - self._state = self._upnp.totalbytereceived() - elif self.type == "byte_sent": - self._state = self._upnp.totalbytesent() - elif self.type == "packets_in": - self._state = self._upnp.totalpacketreceived() - elif self.type == "packets_out": - self._state = self._upnp.totalpacketsent() + if self.type == BYTES_RECEIVED: + self._state = await self._service.get_total_bytes_received() + elif self.type == BYTES_SENT: + self._state = await self._service.get_total_bytes_sent() + elif self.type == PACKETS_RECEIVED: + self._state = await self._service.get_total_packets_received() + elif self.type == PACKETS_SENT: + self._state = await self._service.get_total_packets_sent() diff --git a/homeassistant/components/sensor/uscis.py b/homeassistant/components/sensor/uscis.py new file mode 100644 index 00000000000..ed3c9ca8587 --- /dev/null +++ b/homeassistant/components/sensor/uscis.py @@ -0,0 +1,87 @@ +""" +Support for USCIS Case Status. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.uscis/ +""" + +import logging +from datetime import timedelta +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_FRIENDLY_NAME + + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['uscisstatus==0.1.1'] + +DEFAULT_NAME = "USCIS" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string, + vol.Required('case_id'): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setting the platform in HASS and Case Information.""" + uscis = UscisSensor(config['case_id'], config[CONF_FRIENDLY_NAME]) + uscis.update() + if uscis.valid_case_id: + add_devices([uscis]) + else: + _LOGGER.error("Setup USCIS Sensor Fail" + " check if your Case ID is Valid") + + +class UscisSensor(Entity): + """USCIS Sensor will check case status on daily basis.""" + + MIN_TIME_BETWEEN_UPDATES = timedelta(hours=24) + + CURRENT_STATUS = "current_status" + LAST_CASE_UPDATE = "last_update_date" + + def __init__(self, case, name): + """Initialize the sensor.""" + self._state = None + self._case_id = case + self._attributes = None + self.valid_case_id = None + self._name = name + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Using Request to access USCIS website and fetch data.""" + import uscisstatus + try: + status = uscisstatus.get_case_status(self._case_id) + self._attributes = { + self.CURRENT_STATUS: status['status'] + } + self._state = status['date'] + self.valid_case_id = True + + except ValueError: + _LOGGER("Please Check that you have valid USCIS case id") + self.valid_case_id = False diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 519d3b98704..746c3c7f483 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -395,6 +395,18 @@ snips: intent_filter: description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. example: turnOnLights, turnOffLights + feedback_on: + description: Turns feedback sounds on. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom + feedback_off: + description: Turns feedback sounds off. + fields: + site_id: + description: Site to turn sounds on, defaults to all sites (optional) + example: bedroom input_boolean: toggle: diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py index 854abdda7bc..3f27c91e7c5 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.1'] +REQUIREMENTS = ['skybellpy==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py index 1241679770b..b35cd8cf5a8 100644 --- a/homeassistant/components/smappee.py +++ b/homeassistant/components/smappee.py @@ -110,6 +110,7 @@ class Smappee(object): self.locations = {} self.info = {} self.consumption = {} + self.sensor_consumption = {} self.instantaneous = {} if self._remote_active or self._local_active: @@ -124,11 +125,22 @@ class Smappee(object): for location in service_locations: location_id = location.get('serviceLocationId') if location_id is not None: + self.sensor_consumption[location_id] = {} self.locations[location_id] = location.get('name') self.info[location_id] = self._smappy \ .get_service_location_info(location_id) _LOGGER.debug("Remote info %s %s", - self.locations, self.info) + self.locations, self.info[location_id]) + + for sensors in self.info[location_id].get('sensors'): + sensor_id = sensors.get('id') + self.sensor_consumption[location_id]\ + .update({sensor_id: self.get_sensor_consumption( + location_id, sensor_id, + aggregation=3, delta=1440)}) + _LOGGER.debug("Remote sensors %s %s", + self.locations, + self.sensor_consumption[location_id]) self.consumption[location_id] = self.get_consumption( location_id, aggregation=3, delta=1440) @@ -190,7 +202,8 @@ class Smappee(object): "Error getting comsumption from Smappee cloud. (%s)", error) - def get_sensor_consumption(self, location_id, sensor_id): + def get_sensor_consumption(self, location_id, sensor_id, + aggregation, delta): """Update data from Smappee.""" # Start & End accept epoch (in milliseconds), # datetime and pandas timestamps @@ -203,13 +216,13 @@ class Smappee(object): if not self.is_remote_active: return - start = datetime.utcnow() - timedelta(minutes=30) end = datetime.utcnow() + start = end - timedelta(minutes=delta) try: return self._smappy.get_sensor_consumption(location_id, sensor_id, start, - end, 1) + end, aggregation) except RequestException as error: _LOGGER.error( "Error getting comsumption from Smappee cloud. (%s)", diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index d085b1279cb..812906e7be9 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -4,13 +4,13 @@ Support for Snips on-device ASR and NLU. For more details about this component, please refer to the documentation at https://home-assistant.io/components/snips/ """ -import asyncio import json import logging from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback from homeassistant.helpers import intent, config_validation as cv import homeassistant.components.mqtt as mqtt @@ -19,11 +19,18 @@ DEPENDENCIES = ['mqtt'] CONF_INTENTS = 'intents' CONF_ACTION = 'action' +CONF_FEEDBACK = 'feedback_sounds' +CONF_PROBABILITY = 'probability_threshold' +CONF_SITE_IDS = 'site_ids' SERVICE_SAY = 'say' SERVICE_SAY_ACTION = 'say_action' +SERVICE_FEEDBACK_ON = 'feedback_on' +SERVICE_FEEDBACK_OFF = 'feedback_off' INTENT_TOPIC = 'hermes/intent/#' +FEEDBACK_ON_TOPIC = 'hermes/feedback/sound/toggleOn' +FEEDBACK_OFF_TOPIC = 'hermes/feedback/sound/toggleOff' ATTR_TEXT = 'text' ATTR_SITE_ID = 'site_id' @@ -34,7 +41,12 @@ ATTR_INTENT_FILTER = 'intent_filter' _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: {} + DOMAIN: vol.Schema({ + vol.Optional(CONF_FEEDBACK): cv.boolean, + vol.Optional(CONF_PROBABILITY, default=0): vol.Coerce(float), + vol.Optional(CONF_SITE_IDS, default=['default']): + vol.All(cv.ensure_list, [cv.string]), + }), }, extra=vol.ALLOW_EXTRA) INTENT_SCHEMA = vol.Schema({ @@ -57,7 +69,6 @@ SERVICE_SCHEMA_SAY = vol.Schema({ vol.Optional(ATTR_SITE_ID, default='default'): str, vol.Optional(ATTR_CUSTOM_DATA, default=''): str }) - SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Required(ATTR_TEXT): str, vol.Optional(ATTR_SITE_ID, default='default'): str, @@ -65,13 +76,31 @@ SERVICE_SCHEMA_SAY_ACTION = vol.Schema({ vol.Optional(ATTR_CAN_BE_ENQUEUED, default=True): cv.boolean, vol.Optional(ATTR_INTENT_FILTER): vol.All(cv.ensure_list), }) +SERVICE_SCHEMA_FEEDBACK = vol.Schema({ + vol.Optional(ATTR_SITE_ID, default='default'): str +}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Activate Snips component.""" - @asyncio.coroutine - def message_received(topic, payload, qos): + @callback + def async_set_feedback(site_ids, state): + """Set Feedback sound state.""" + site_ids = (site_ids if site_ids + else config[DOMAIN].get(CONF_SITE_IDS)) + topic = (FEEDBACK_ON_TOPIC if state + else FEEDBACK_OFF_TOPIC) + for site_id in site_ids: + payload = json.dumps({'siteId': site_id}) + hass.components.mqtt.async_publish( + FEEDBACK_ON_TOPIC, None, qos=0, retain=False) + hass.components.mqtt.async_publish( + topic, payload, qos=int(state), retain=state) + + if CONF_FEEDBACK in config[DOMAIN]: + async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) + + async def message_received(topic, payload, qos): """Handle new messages on MQTT.""" _LOGGER.debug("New intent: %s", payload) @@ -81,6 +110,13 @@ def async_setup(hass, config): _LOGGER.error('Received invalid JSON: %s', payload) return + if (request['intent']['probability'] + < config[DOMAIN].get(CONF_PROBABILITY)): + _LOGGER.warning("Intent below probaility threshold %s < %s", + request['intent']['probability'], + config[DOMAIN].get(CONF_PROBABILITY)) + return + try: request = INTENT_SCHEMA(request) except vol.Invalid as err: @@ -97,7 +133,7 @@ def async_setup(hass, config): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} try: - intent_response = yield from intent.async_handle( + intent_response = await intent.async_handle( hass, DOMAIN, intent_type, slots, request['input']) if 'plain' in intent_response.speech: snips_response = intent_response.speech['plain']['speech'] @@ -115,11 +151,10 @@ def async_setup(hass, config): mqtt.async_publish(hass, 'hermes/dialogueManager/endSession', json.dumps(notification)) - yield from hass.components.mqtt.async_subscribe( + await hass.components.mqtt.async_subscribe( INTENT_TOPIC, message_received) - @asyncio.coroutine - def snips_say(call): + async def snips_say(call): """Send a Snips notification message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -129,8 +164,7 @@ def async_setup(hass, config): json.dumps(notification)) return - @asyncio.coroutine - def snips_say_action(call): + async def snips_say_action(call): """Send a Snips action message.""" notification = {'siteId': call.data.get(ATTR_SITE_ID, 'default'), 'customData': call.data.get(ATTR_CUSTOM_DATA, ''), @@ -144,12 +178,26 @@ def async_setup(hass, config): json.dumps(notification)) return + async def feedback_on(call): + """Turn feedback sounds on.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), True) + + async def feedback_off(call): + """Turn feedback sounds off.""" + async_set_feedback(call.data.get(ATTR_SITE_ID), False) + hass.services.async_register( DOMAIN, SERVICE_SAY, snips_say, schema=SERVICE_SCHEMA_SAY) hass.services.async_register( DOMAIN, SERVICE_SAY_ACTION, snips_say_action, schema=SERVICE_SCHEMA_SAY_ACTION) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_ON, feedback_on, + schema=SERVICE_SCHEMA_FEEDBACK) + hass.services.async_register( + DOMAIN, SERVICE_FEEDBACK_OFF, feedback_off, + schema=SERVICE_SCHEMA_FEEDBACK) return True diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 49eb5d32110..40ebb54b603 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -84,12 +84,12 @@ class SmartPlugSwitch(SwitchDevice): """Update edimax switch.""" try: self._now_power = float(self.smartplug.now_power) - except ValueError: + except (TypeError, ValueError): self._now_power = None try: self._now_energy_day = float(self.smartplug.now_energy_day) - except ValueError: + except (TypeError, ValueError): self._now_energy_day = None self._state = self.smartplug.state == 'ON' diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py new file mode 100644 index 00000000000..891525d3979 --- /dev/null +++ b/homeassistant/components/switch/eufy.py @@ -0,0 +1,73 @@ +""" +Support for Eufy switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.eufy/ +""" +import logging + +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['eufy'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Eufy switches.""" + if discovery_info is None: + return + add_devices([EufySwitch(discovery_info)], True) + + +class EufySwitch(SwitchDevice): + """Representation of a Eufy switch.""" + + def __init__(self, device): + """Initialize the light.""" + # pylint: disable=import-error + import lakeside + + self._state = None + self._name = device['name'] + self._address = device['address'] + self._code = device['code'] + self._type = device['type'] + self._switch = lakeside.switch(self._address, self._code, self._type) + self._switch.connect() + + def update(self): + """Synchronise state from the switch.""" + self._switch.update() + self._state = self._switch.power + + @property + def unique_id(self): + """Return the ID of this light.""" + return self._address + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + try: + self._switch.set_state(True) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(power=True) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + try: + self._switch.set_state(False) + except BrokenPipeError: + self._switch.connect() + self._switch.set_state(False) diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py new file mode 100755 index 00000000000..c8313b0dfef --- /dev/null +++ b/homeassistant/components/switch/fritzbox.py @@ -0,0 +1,104 @@ +""" +Support for AVM Fritz!Box smarthome switch devices. + +For more details about this component, please refer to the documentation at +http://home-assistant.io/components/switch.fritzbox/ +""" +import logging + +import requests + +from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN +from homeassistant.components.fritzbox import ( + ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED) +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +DEPENDENCIES = ['fritzbox'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TOTAL_CONSUMPTION = 'total_consumption' +ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' + +ATTR_TEMPERATURE_UNIT = 'temperature_unit' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Fritzbox smarthome switch platform.""" + devices = [] + fritz_list = hass.data[FRITZBOX_DOMAIN] + + for fritz in fritz_list: + device_list = fritz.get_devices() + for device in device_list: + if device.has_switch: + devices.append(FritzboxSwitch(device, fritz)) + + add_devices(devices) + + +class FritzboxSwitch(SwitchDevice): + """The switch class for Fritzbox switches.""" + + def __init__(self, device, fritz): + """Initialize the switch.""" + self._device = device + self._fritz = fritz + + @property + def available(self): + """Return if switch is available.""" + return self._device.present + + @property + def name(self): + """Return the name of the device.""" + return self._device.name + + @property + def is_on(self): + """Return true if the switch is on.""" + return self._device.switch_state + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self._device.set_switch_state_on() + + def turn_off(self, **kwargs): + """Turn the switch off.""" + self._device.set_switch_state_off() + + def update(self): + """Get latest data and states from the device.""" + try: + self._device.update() + except requests.exceptions.HTTPError as ex: + _LOGGER.warning("Fritzhome connection error: %s", ex) + self._fritz.login() + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attrs = {} + attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock + attrs[ATTR_STATE_LOCKED] = self._device.lock + + if self._device.has_powermeter: + attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( + (self._device.energy or 0.0) / 100000) + attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = \ + ATTR_TOTAL_CONSUMPTION_UNIT_VALUE + if self._device.has_temperature_sensor: + attrs[ATTR_TEMPERATURE] = \ + str(self.hass.config.units.temperature( + self._device.temperature, TEMP_CELSIUS)) + attrs[ATTR_TEMPERATURE_UNIT] = \ + self.hass.config.units.temperature_unit + return attrs + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.power / 1000 diff --git a/homeassistant/components/switch/hive.py b/homeassistant/components/switch/hive.py index 67ebe95ba8e..49fc9696b5e 100644 --- a/homeassistant/components/switch/hive.py +++ b/homeassistant/components/switch/hive.py @@ -28,6 +28,7 @@ class HiveDevicePlug(SwitchDevice): self.node_name = hivedevice["Hive_NodeName"] self.device_type = hivedevice["HA_DeviceType"] self.session = hivesession + self.attributes = {} self.data_updatesource = '{}.{}'.format(self.device_type, self.node_id) self.session.entities.append(self) @@ -42,6 +43,11 @@ class HiveDevicePlug(SwitchDevice): """Return the name of this Switch device if any.""" return self.node_name + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self.attributes + @property def current_power_w(self): """Return the current power usage in W.""" @@ -67,3 +73,5 @@ class HiveDevicePlug(SwitchDevice): def update(self): """Update all Node data from Hive.""" self.session.core.update_data(self.node_id) + self.attributes = self.session.attributes.state_attributes( + self.node_id) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py new file mode 100644 index 00000000000..6b97200ba49 --- /dev/null +++ b/homeassistant/components/switch/homekit_controller.py @@ -0,0 +1,68 @@ +""" +Support for Homekit switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.homekit_controller/ +""" +import json +import logging + +from homeassistant.components.homekit_controller import (HomeKitEntity, + KNOWN_ACCESSORIES) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit switch support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitSwitch(accessory, discovery_info)], True) + + +class HomeKitSwitch(HomeKitEntity, SwitchDevice): + """Representation of a Homekit switch.""" + + def __init__(self, *args): + """Initialise the switch.""" + super().__init__(*args) + self._on = None + + def update_characteristics(self, characteristics): + """Synchronise the switch state with Home Assistant.""" + # pylint: disable=import-error + import homekit + + for characteristic in characteristics: + ctype = characteristic['type'] + ctype = homekit.CharacteristicsTypes.get_short(ctype) + if ctype == "on": + self._chars['on'] = characteristic['iid'] + self._on = characteristic['value'] + elif ctype == "outlet-in-use": + self._chars['outlet-in-use'] = characteristic['iid'] + + @property + def is_on(self): + """Return true if device is on.""" + return self._on + + def turn_on(self, **kwargs): + """Turn the specified switch on.""" + self._on = True + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': True}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + + def turn_off(self, **kwargs): + """Turn the specified switch off.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['on'], + 'value': False}] + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 17aa66ea825..999b584360c 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -37,6 +37,7 @@ ATTR_CACHE = 'cache' ATTR_LANGUAGE = 'language' ATTR_MESSAGE = 'message' ATTR_OPTIONS = 'options' +ATTR_PLATFORM = 'platform' CONF_CACHE = 'cache' CONF_CACHE_DIR = 'cache_dir' @@ -77,8 +78,7 @@ SCHEMA_SERVICE_SAY = vol.Schema({ SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({}) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up TTS.""" tts = SpeechManager(hass) @@ -88,27 +88,27 @@ def async_setup(hass, config): cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR) time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY) - yield from tts.async_init_cache(use_cache, cache_dir, time_memory) + await tts.async_init_cache(use_cache, cache_dir, time_memory) except (HomeAssistantError, KeyError) as err: _LOGGER.error("Error on cache init %s", err) return False hass.http.register_view(TextToSpeechView(tts)) + hass.http.register_view(TextToSpeechUrlView(tts)) - @asyncio.coroutine - def async_setup_platform(p_type, p_config, disc_info=None): + async def async_setup_platform(p_type, p_config, disc_info=None): """Set up a TTS platform.""" - platform = yield from async_prepare_setup_platform( + platform = await async_prepare_setup_platform( hass, config, DOMAIN, p_type) if platform is None: return try: if hasattr(platform, 'async_get_engine'): - provider = yield from platform.async_get_engine( + provider = await platform.async_get_engine( hass, p_config) else: - provider = yield from hass.async_add_job( + provider = await hass.async_add_job( platform.get_engine, hass, p_config) if provider is None: @@ -120,8 +120,7 @@ def async_setup(hass, config): _LOGGER.exception("Error setting up platform %s", p_type) return - @asyncio.coroutine - def async_say_handle(service): + async def async_say_handle(service): """Service handle for say.""" entity_ids = service.data.get(ATTR_ENTITY_ID) message = service.data.get(ATTR_MESSAGE) @@ -130,7 +129,7 @@ def async_setup(hass, config): options = service.data.get(ATTR_OPTIONS) try: - url = yield from tts.async_get_url( + url = await tts.async_get_url( p_type, message, cache=cache, language=language, options=options ) @@ -146,7 +145,7 @@ def async_setup(hass, config): if entity_ids: data[ATTR_ENTITY_ID] = entity_ids - yield from hass.services.async_call( + await hass.services.async_call( DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) hass.services.async_register( @@ -157,12 +156,11 @@ def async_setup(hass, config): in config_per_platform(config, DOMAIN)] if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + await asyncio.wait(setup_tasks, loop=hass.loop) - @asyncio.coroutine - def async_clear_cache_handle(service): + async def async_clear_cache_handle(service): """Handle clear cache service call.""" - yield from tts.async_clear_cache() + await tts.async_clear_cache() hass.services.async_register( DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle, @@ -185,8 +183,7 @@ class SpeechManager(object): self.file_cache = {} self.mem_cache = {} - @asyncio.coroutine - def async_init_cache(self, use_cache, cache_dir, time_memory): + async def async_init_cache(self, use_cache, cache_dir, time_memory): """Init config folder and load file cache.""" self.use_cache = use_cache self.time_memory = time_memory @@ -201,7 +198,7 @@ class SpeechManager(object): return cache_dir try: - self.cache_dir = yield from self.hass.async_add_job( + self.cache_dir = await self.hass.async_add_job( init_tts_cache_dir, cache_dir) except OSError as err: raise HomeAssistantError("Can't init cache dir {}".format(err)) @@ -222,15 +219,14 @@ class SpeechManager(object): return cache try: - cache_files = yield from self.hass.async_add_job(get_cache_files) + cache_files = await self.hass.async_add_job(get_cache_files) except OSError as err: raise HomeAssistantError("Can't read cache dir {}".format(err)) if cache_files: self.file_cache.update(cache_files) - @asyncio.coroutine - def async_clear_cache(self): + async def async_clear_cache(self): """Read file cache and delete files.""" self.mem_cache = {} @@ -243,7 +239,7 @@ class SpeechManager(object): _LOGGER.warning( "Can't remove cache file '%s': %s", filename, err) - yield from self.hass.async_add_job(remove_files) + await self.hass.async_add_job(remove_files) self.file_cache = {} @callback @@ -254,9 +250,8 @@ class SpeechManager(object): provider.name = engine self.providers[engine] = provider - @asyncio.coroutine - def async_get_url(self, engine, message, cache=None, language=None, - options=None): + async def async_get_url(self, engine, message, cache=None, language=None, + options=None): """Get URL for play message. This method is a coroutine. @@ -301,21 +296,20 @@ class SpeechManager(object): self.hass.async_add_job(self.async_file_to_mem(key)) # Load speech from provider into memory else: - filename = yield from self.async_get_tts_audio( + filename = await self.async_get_tts_audio( engine, key, message, use_cache, language, options) return "{}/api/tts_proxy/{}".format( self.hass.config.api.base_url, filename) - @asyncio.coroutine - def async_get_tts_audio(self, engine, key, message, cache, language, - options): + async def async_get_tts_audio(self, engine, key, message, cache, language, + options): """Receive TTS and store for view in cache. This method is a coroutine. """ provider = self.providers[engine] - extension, data = yield from provider.async_get_tts_audio( + extension, data = await provider.async_get_tts_audio( message, language, options) if data is None or extension is None: @@ -337,8 +331,7 @@ class SpeechManager(object): return filename - @asyncio.coroutine - def async_save_tts_audio(self, key, filename, data): + async def async_save_tts_audio(self, key, filename, data): """Store voice data to file and file_cache. This method is a coroutine. @@ -351,13 +344,12 @@ class SpeechManager(object): speech.write(data) try: - yield from self.hass.async_add_job(save_speech) + await self.hass.async_add_job(save_speech) self.file_cache[key] = filename except OSError: _LOGGER.error("Can't write %s", filename) - @asyncio.coroutine - def async_file_to_mem(self, key): + async def async_file_to_mem(self, key): """Load voice from file cache into memory. This method is a coroutine. @@ -374,7 +366,7 @@ class SpeechManager(object): return speech.read() try: - data = yield from self.hass.async_add_job(load_speech) + data = await self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] raise HomeAssistantError("Can't read {}".format(voice_file)) @@ -396,8 +388,7 @@ class SpeechManager(object): self.hass.loop.call_later(self.time_memory, async_remove_from_mem) - @asyncio.coroutine - def async_read_tts(self, filename): + async def async_read_tts(self, filename): """Read a voice file and return binary. This method is a coroutine. @@ -412,7 +403,7 @@ class SpeechManager(object): if key not in self.mem_cache: if key not in self.file_cache: raise HomeAssistantError("{} not in cache!".format(key)) - yield from self.async_file_to_mem(key) + await self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) return (content, self.mem_cache[key][MEM_CACHE_VOICE]) @@ -490,6 +481,45 @@ class Provider(object): ft.partial(self.get_tts_audio, message, language, options=options)) +class TextToSpeechUrlView(HomeAssistantView): + """TTS view to get a url to a generated speech file.""" + + requires_auth = True + url = '/api/tts_get_url' + name = 'api:tts:geturl' + + def __init__(self, tts): + """Initialize a tts view.""" + self.tts = tts + + async def post(self, request): + """Generate speech and provide url.""" + try: + data = await request.json() + except ValueError: + return self.json_message('Invalid JSON specified', 400) + if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE): + return self.json_message('Must specify platform and message', 400) + + p_type = data[ATTR_PLATFORM] + message = data[ATTR_MESSAGE] + cache = data.get(ATTR_CACHE) + language = data.get(ATTR_LANGUAGE) + options = data.get(ATTR_OPTIONS) + + try: + url = await self.tts.async_get_url( + p_type, message, cache=cache, language=language, + options=options + ) + resp = self.json({'url': url}, 200) + except HomeAssistantError as err: + _LOGGER.error("Error on init tts: %s", err) + resp = self.json({'error': err}, 400) + + return resp + + class TextToSpeechView(HomeAssistantView): """TTS view to serve a speech audio.""" @@ -501,11 +531,10 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.tts = tts - @asyncio.coroutine - def get(self, request, filename): + async def get(self, request, filename): """Start a get request.""" try: - content, data = yield from self.tts.async_read_tts(filename) + content, data = await self.tts.async_read_tts(filename) except HomeAssistantError as err: _LOGGER.error("Error on load tts: %s", err) return web.Response(status=404) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 960d8f3780e..dd611090c22 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/upnp/ """ from ipaddress import ip_address import logging +import asyncio import voluptuous as vol @@ -14,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.util import get_local_ip -REQUIREMENTS = ['miniupnpc==2.0.2'] +REQUIREMENTS = ['pyupnp-async==0.1.0.1'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['api'] DOMAIN = 'upnp' -DATA_UPNP = 'UPNP' +DATA_UPNP = 'upnp_device' CONF_LOCAL_IP = 'local_ip' CONF_ENABLE_PORT_MAPPING = 'port_mapping' @@ -33,6 +34,11 @@ CONF_HASS = 'hass' NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP Setup' +IGD_DEVICE = 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' +PPP_SERVICE = 'urn:schemas-upnp-org:service:WANPPPConnection:1' +IP_SERVICE = 'urn:schemas-upnp-org:service:WANIPConnection:1' +CIC_SERVICE = 'urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1' + UNITS = { "Bytes": 1, "KBytes": 1024, @@ -51,8 +57,7 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=import-error, no-member, broad-except, c-extension-no-member -def setup(hass, config): +async def async_setup(hass, config): """Register a port mapping for Home Assistant via UPnP.""" config = config[DOMAIN] host = config.get(CONF_LOCAL_IP) @@ -67,21 +72,35 @@ def setup(hass, config): 'Unable to determine local IP. Add it to your configuration.') return False - import miniupnpc + import pyupnp_async + from pyupnp_async.error import UpnpSoapError - upnp = miniupnpc.UPnP() - hass.data[DATA_UPNP] = upnp - - upnp.discoverdelay = 200 - upnp.discover() - try: - upnp.selectigd() - except Exception: - _LOGGER.exception("Error when attempting to discover an UPnP IGD") + service = None + resp = await pyupnp_async.msearch_first(search_target=IGD_DEVICE) + if not resp: return False - unit = config.get(CONF_UNITS) - discovery.load_platform(hass, 'sensor', DOMAIN, {'unit': unit}, config) + try: + device = await resp.get_device() + hass.data[DATA_UPNP] = device + for _service in device.services: + if _service['serviceType'] == PPP_SERVICE: + service = device.find_first_service(PPP_SERVICE) + if _service['serviceType'] == IP_SERVICE: + service = device.find_first_service(IP_SERVICE) + if _service['serviceType'] == CIC_SERVICE: + unit = config.get(CONF_UNITS) + discovery.load_platform(hass, 'sensor', + DOMAIN, + {'unit': unit}, + config) + except UpnpSoapError as error: + _LOGGER.error(error) + return False + + if not service: + _LOGGER.warning("Could not find any UPnP IGD") + return False port_mapping = config.get(CONF_ENABLE_PORT_MAPPING) if not port_mapping: @@ -98,12 +117,12 @@ def setup(hass, config): if internal == CONF_HASS: internal = internal_port try: - upnp.addportmapping( - external, 'TCP', host, internal, 'Home Assistant', '') + await service.add_port_mapping(internal, external, host, 'TCP', + desc='Home Assistant') registered.append(external) - except Exception: - _LOGGER.exception("UPnP failed to configure port mapping for %s", - external) + _LOGGER.debug("external %s -> %s @ %s", external, internal, host) + except UpnpSoapError as error: + _LOGGER.error(error) hass.components.persistent_notification.create( 'ERROR: tcp port {} is already mapped in your router.' '
Please disable port_mapping in the upnp ' @@ -113,11 +132,13 @@ def setup(hass, config): title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) - def deregister_port(event): + async def deregister_port(event): """De-register the UPnP port mapping.""" - for external in registered: - upnp.deleteportmapping(external, 'TCP') + tasks = [service.delete_port_mapping(external, 'TCP') + for external in registered] + if tasks: + await asyncio.wait(tasks) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port) return True diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 095e8bfb124..1b7d5685231 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -57,7 +57,7 @@ VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): cv.Dict, + vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), }) SERVICE_TO_METHOD = { @@ -76,7 +76,6 @@ SERVICE_TO_METHOD = { } DEFAULT_NAME = 'Vacuum cleaner robot' -DEFAULT_ICON = 'mdi:roomba' SUPPORT_TURN_ON = 1 SUPPORT_TURN_OFF = 2 diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index 668e3ca37e6..bd501167ffa 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -7,7 +7,7 @@ https://home-assistant.io/components/demo/ import logging from homeassistant.components.vacuum import ( - ATTR_CLEANED_AREA, DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, + ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -66,11 +66,6 @@ class DemoVacuum(VacuumDevice): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for a demo vacuum.""" diff --git a/homeassistant/components/vacuum/dyson.py b/homeassistant/components/vacuum/dyson.py index aa05d004a35..d423a8dacf5 100644 --- a/homeassistant/components/vacuum/dyson.py +++ b/homeassistant/components/vacuum/dyson.py @@ -24,8 +24,6 @@ DEPENDENCIES = ['dyson'] DYSON_360_EYE_DEVICES = "dyson_360_eye_devices" -ICON = 'mdi:roomba' - SUPPORT_DYSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \ SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | SUPPORT_STATUS | \ SUPPORT_BATTERY | SUPPORT_STOP @@ -56,7 +54,6 @@ class Dyson360EyeDevice(VacuumDevice): """Dyson 360 Eye robot vacuum device.""" _LOGGER.debug("Creating device %s", device.name) self._device = device - self._icon = ICON @asyncio.coroutine def async_added_to_hass(self): @@ -82,11 +79,6 @@ class Dyson360EyeDevice(VacuumDevice): """Return the name of the device.""" return self._device.name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index f4c640f1fc7..ef3bb0f636b 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -12,7 +12,7 @@ import voluptuous as vol import homeassistant.components.mqtt as mqtt from homeassistant.components.mqtt import MqttAvailability from homeassistant.components.vacuum import ( - DEFAULT_ICON, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, + SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice) @@ -340,11 +340,6 @@ class MqttVacuum(MqttAvailability, VacuumDevice): """Return the name of the vacuum.""" return self._name - @property - def icon(self): - """Return the icon for the vacuum.""" - return DEFAULT_ICON - @property def should_poll(self): """No polling needed for an MQTT vacuum.""" diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 2a4eb2d5e7f..9eba34cea32 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -24,8 +24,6 @@ SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ SUPPORT_STATUS | SUPPORT_MAP -ICON = 'mdi:roomba' - ATTR_CLEAN_START = 'clean_start' ATTR_CLEAN_STOP = 'clean_stop' ATTR_CLEAN_AREA = 'clean_area' @@ -131,11 +129,6 @@ class NeatoConnectedVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def supported_features(self): """Flag vacuum cleaner robot features that are supported.""" diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index b983b20bd0c..44d22e03f41 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -43,7 +43,6 @@ DEFAULT_CERT = '/etc/ssl/certs/ca-certificates.crt' DEFAULT_CONTINUOUS = True DEFAULT_NAME = 'Roomba' -ICON = 'mdi:roomba' PLATFORM = 'roomba' FAN_SPEED_AUTOMATIC = 'Automatic' @@ -165,11 +164,6 @@ class RoombaVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return ICON - @property def device_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index fea365ac7c7..863157074bc 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -4,93 +4,93 @@ turn_on: description: Start a new cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' turn_off: description: Stop the current cleaning task and return to home. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' stop: description: Stop the current cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' locate: description: Locate the vacuum cleaner robot. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' start_pause: description: Start, pause, or resume the cleaning task. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' return_to_base: description: Tell the vacuum cleaner to return to its dock. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' clean_spot: description: Tell the vacuum cleaner to do a spot clean-up. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' send_command: description: Send a raw command to the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' command: description: Command to execute. example: 'set_dnd_timer' params: description: Parameters for the command. - example: '[22,0,6,0]' + example: '{ "key": "value" }' set_fan_speed: description: Set the fan speed of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' fan_speed: - description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium', or by percentage, between 0 and 100. + description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium' or by percentage, between 0 and 100. example: 'low' xiaomi_remote_control_start: description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_stop: description: Stop remote control mode of the vacuum cleaner. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' xiaomi_remote_control_move: description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. @@ -106,7 +106,7 @@ xiaomi_remote_control_move_step: description: Remote control the vacuum cleaner, only makes one move and then stops. fields: entity_id: - description: Name of the botvac entity. + description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' velocity: description: Speed, between -0.29 and 0.29. diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index b2451ed495c..620014a1bae 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -24,7 +24,6 @@ REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Xiaomi Vacuum cleaner' -ICON = 'mdi:roomba' DATA_KEY = 'vacuum.xiaomi_miio' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -142,7 +141,6 @@ class MiroboVacuum(VacuumDevice): def __init__(self, name, vacuum): """Initialize the Xiaomi vacuum cleaner robot handler.""" self._name = name - self._icon = ICON self._vacuum = vacuum self.vacuum_state = None @@ -158,11 +156,6 @@ class MiroboVacuum(VacuumDevice): """Return the name of the device.""" return self._name - @property - def icon(self): - """Return the icon to use for device.""" - return self._icon - @property def status(self): """Return the status of the vacuum cleaner.""" diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index f9610e469b2..5987cf7621f 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -32,6 +32,7 @@ DEFAULT_NAME = 'Yweather' SCAN_INTERVAL = timedelta(minutes=10) CONDITION_CLASSES = { + 'clear-night': [31], 'cloudy': [26, 27, 28, 29, 30], 'fog': [19, 20, 21, 22, 23], 'hail': [17, 18, 35], diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 39419034545..73c1fdf9075 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery, entity from homeassistant.util import slugify REQUIREMENTS = [ - 'bellows==0.5.1', + 'bellows==0.5.2', 'zigpy==0.0.3', 'zigpy-xbee==0.0.2', ] diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 69491af1aad..46bb2f7bfe2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -27,7 +27,7 @@ At a minimum, each config flow will have to define a version number and the 'init' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.ConfigFlowHandler): + class ExampleConfigFlow(config_entries.FlowHandler): VERSION = 1 @@ -115,6 +115,7 @@ import logging import os import uuid +from . import data_entry_flow from .core import callback from .exceptions import HomeAssistantError from .setup import async_setup_component, async_process_deps_reqs @@ -130,17 +131,11 @@ FLOWS = [ 'hue', ] -SOURCE_USER = 'user' -SOURCE_DISCOVERY = 'discovery' PATH_CONFIG = '.config_entries.json' SAVE_DELAY = 1 -RESULT_TYPE_FORM = 'form' -RESULT_TYPE_CREATE_ENTRY = 'create_entry' -RESULT_TYPE_ABORT = 'abort' - ENTRY_STATE_LOADED = 'loaded' ENTRY_STATE_SETUP_ERROR = 'setup_error' ENTRY_STATE_NOT_LOADED = 'not_loaded' @@ -187,24 +182,29 @@ class ConfigEntry: if not isinstance(result, bool): _LOGGER.error('%s.async_config_entry did not return boolean', - self.domain) + component.DOMAIN) result = False except Exception: # pylint: disable=broad-except _LOGGER.exception('Error setting up entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) result = False + # Only store setup result as state if it was not forwarded. + if self.domain != component.DOMAIN: + return + if result: self.state = ENTRY_STATE_LOADED else: self.state = ENTRY_STATE_SETUP_ERROR - async def async_unload(self, hass): + async def async_unload(self, hass, *, component=None): """Unload an entry. Returns if unload is possible and was successful. """ - component = getattr(hass.components, self.domain) + if component is None: + component = getattr(hass.components, self.domain) supports_unload = hasattr(component, 'async_unload_entry') @@ -216,13 +216,13 @@ class ConfigEntry: if not isinstance(result, bool): _LOGGER.error('%s.async_unload_entry did not return boolean', - self.domain) + component.DOMAIN) result = False return result except Exception: # pylint: disable=broad-except _LOGGER.exception('Error unloading entry %s for %s', - self.title, self.domain) + self.title, component.DOMAIN) self.state = ENTRY_STATE_FAILED_UNLOAD return False @@ -246,18 +246,6 @@ class UnknownEntry(ConfigError): """Unknown entry specified.""" -class UnknownHandler(ConfigError): - """Unknown handler specified.""" - - -class UnknownFlow(ConfigError): - """Uknown flow specified.""" - - -class UnknownStep(ConfigError): - """Unknown step specified.""" - - class ConfigEntries: """Manage the configuration entries. @@ -267,7 +255,8 @@ class ConfigEntries: def __init__(self, hass, hass_config): """Initialize the entry manager.""" self.hass = hass - self.flow = FlowManager(hass, hass_config, self._async_add_entry) + self.flow = data_entry_flow.FlowManager( + hass, self._async_create_flow, self._async_save_entry) self._hass_config = hass_config self._entries = None self._sched_save = None @@ -322,8 +311,45 @@ class ConfigEntries: entries = await self.hass.async_add_job(load_json, path) self._entries = [ConfigEntry(**entry) for entry in entries] - async def _async_add_entry(self, entry): + async def async_forward_entry_setup(self, entry, component): + """Forward the setup of an entry to a different component. + + By default an entry is setup with the component it belongs to. If that + component also has related platforms, the component will have to + forward the entry to be setup by that component. + + You don't want to await this coroutine if it is called as part of the + setup of a component, because it can cause a deadlock. + """ + # Setup Component if not set up yet + if component not in self.hass.config.components: + result = await async_setup_component( + self.hass, component, self._hass_config) + + if not result: + return False + + await entry.async_setup( + self.hass, component=getattr(self.hass.components, component)) + + async def async_forward_entry_unload(self, entry, component): + """Forward the unloading of an entry to a different component.""" + # It was never loaded. + if component not in self.hass.config.components: + return True + + return await entry.async_unload( + self.hass, component=getattr(self.hass.components, component)) + + async def _async_save_entry(self, result): """Add an entry.""" + entry = ConfigEntry( + version=result['version'], + domain=result['handler'], + title=result['title'], + data=result['data'], + source=result['source'], + ) self._entries.append(entry) self._async_schedule_save() @@ -336,6 +362,25 @@ class ConfigEntries: await async_setup_component( self.hass, entry.domain, self._hass_config) + return entry + + async def _async_create_flow(self, handler): + """Create a flow for specified handler. + + Handler key is the domain of the component that we want to setup. + """ + component = getattr(self.hass.components, handler) + handler = HANDLERS.get(handler) + + if handler is None: + raise data_entry_flow.UnknownHandler + + # Make sure requirements and dependencies of component are resolved + await async_process_deps_reqs( + self.hass, self._hass_config, handler, component) + + return handler() + @callback def _async_schedule_save(self): """Schedule saving the entity registry.""" @@ -353,157 +398,3 @@ class ConfigEntries: await self.hass.async_add_job( save_json, self.hass.config.path(PATH_CONFIG), data) - - -class FlowManager: - """Manage all the config flows that are in progress.""" - - def __init__(self, hass, hass_config, async_add_entry): - """Initialize the flow manager.""" - self.hass = hass - self._hass_config = hass_config - self._progress = {} - self._async_add_entry = async_add_entry - - @callback - def async_progress(self): - """Return the flows in progress.""" - return [{ - 'flow_id': flow.flow_id, - 'domain': flow.domain, - 'source': flow.source, - } for flow in self._progress.values()] - - async def async_init(self, domain, *, source=SOURCE_USER, data=None): - """Start a configuration flow.""" - handler = HANDLERS.get(domain) - - if handler is None: - # This will load the component and thus register the handler - component = getattr(self.hass.components, domain) - handler = HANDLERS.get(domain) - - if handler is None: - raise UnknownHandler - - # Make sure requirements and dependencies of component are resolved - await async_process_deps_reqs( - self.hass, self._hass_config, domain, component) - - flow_id = uuid.uuid4().hex - flow = self._progress[flow_id] = handler() - flow.hass = self.hass - flow.domain = domain - flow.flow_id = flow_id - flow.source = source - - if source == SOURCE_USER: - step = 'init' - else: - step = source - - return await self._async_handle_step(flow, step, data) - - async def async_configure(self, flow_id, user_input=None): - """Start or continue a configuration flow.""" - flow = self._progress.get(flow_id) - - if flow is None: - raise UnknownFlow - - step_id, data_schema = flow.cur_step - - if data_schema is not None and user_input is not None: - user_input = data_schema(user_input) - - return await self._async_handle_step( - flow, step_id, user_input) - - @callback - def async_abort(self, flow_id): - """Abort a flow.""" - if self._progress.pop(flow_id, None) is None: - raise UnknownFlow - - async def _async_handle_step(self, flow, step_id, user_input): - """Handle a step of a flow.""" - method = "async_step_{}".format(step_id) - - if not hasattr(flow, method): - self._progress.pop(flow.flow_id) - raise UnknownStep("Handler {} doesn't support step {}".format( - flow.__class__.__name__, step_id)) - - result = await getattr(flow, method)(user_input) - - if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT): - raise ValueError( - 'Handler returned incorrect type: {}'.format(result['type'])) - - if result['type'] == RESULT_TYPE_FORM: - flow.cur_step = (result['step_id'], result['data_schema']) - return result - - # Abort and Success results both finish the flow - self._progress.pop(flow.flow_id) - - if result['type'] == RESULT_TYPE_ABORT: - return result - - entry = ConfigEntry( - version=flow.VERSION, - domain=flow.domain, - title=result['title'], - data=result.pop('data'), - source=flow.source - ) - await self._async_add_entry(entry) - return result - - -class ConfigFlowHandler: - """Handle the configuration flow of a component.""" - - # Set by flow manager - flow_id = None - hass = None - domain = None - source = SOURCE_USER - cur_step = None - - # Set by dev - # VERSION - - @callback - def async_show_form(self, *, step_id, data_schema=None, errors=None): - """Return the definition of a form to gather user input.""" - return { - 'type': RESULT_TYPE_FORM, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'step_id': step_id, - 'data_schema': data_schema, - 'errors': errors, - } - - @callback - def async_create_entry(self, *, title, data): - """Finish config flow and create a config entry.""" - return { - 'type': RESULT_TYPE_CREATE_ENTRY, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'title': title, - 'data': data, - } - - @callback - def async_abort(self, *, reason): - """Abort the config flow.""" - return { - 'type': RESULT_TYPE_ABORT, - 'flow_id': self.flow_id, - 'domain': self.domain, - 'reason': reason - } diff --git a/homeassistant/const.py b/homeassistant/const.py index 4b8d7bcd3bc..43380d00a2d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 67 -PATCH_VERSION = '1' +MINOR_VERSION = 68 +PATCH_VERSION = '0.dev0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py new file mode 100644 index 00000000000..cadec3f3d69 --- /dev/null +++ b/homeassistant/data_entry_flow.py @@ -0,0 +1,167 @@ +"""Classes to help gather user submissions.""" +import logging +import uuid + +from .core import callback +from .exceptions import HomeAssistantError + +_LOGGER = logging.getLogger(__name__) + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' + +RESULT_TYPE_FORM = 'form' +RESULT_TYPE_CREATE_ENTRY = 'create_entry' +RESULT_TYPE_ABORT = 'abort' + + +class FlowError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownHandler(FlowError): + """Unknown handler specified.""" + + +class UnknownFlow(FlowError): + """Uknown flow specified.""" + + +class UnknownStep(FlowError): + """Unknown step specified.""" + + +class FlowManager: + """Manage all the flows that are in progress.""" + + def __init__(self, hass, async_create_flow, async_finish_flow): + """Initialize the flow manager.""" + self.hass = hass + self._progress = {} + self._async_create_flow = async_create_flow + self._async_finish_flow = async_finish_flow + + @callback + def async_progress(self): + """Return the flows in progress.""" + return [{ + 'flow_id': flow.flow_id, + 'handler': flow.handler, + 'source': flow.source, + } for flow in self._progress.values()] + + async def async_init(self, handler, *, source=SOURCE_USER, data=None): + """Start a configuration flow.""" + flow = await self._async_create_flow(handler) + flow.hass = self.hass + flow.handler = handler + flow.flow_id = uuid.uuid4().hex + flow.source = source + self._progress[flow.flow_id] = flow + + if source == SOURCE_USER: + step = 'init' + else: + step = source + + return await self._async_handle_step(flow, step, data) + + async def async_configure(self, flow_id, user_input=None): + """Start or continue a configuration flow.""" + flow = self._progress.get(flow_id) + + if flow is None: + raise UnknownFlow + + step_id, data_schema = flow.cur_step + + if data_schema is not None and user_input is not None: + user_input = data_schema(user_input) + + return await self._async_handle_step( + flow, step_id, user_input) + + @callback + def async_abort(self, flow_id): + """Abort a flow.""" + if self._progress.pop(flow_id, None) is None: + raise UnknownFlow + + async def _async_handle_step(self, flow, step_id, user_input): + """Handle a step of a flow.""" + method = "async_step_{}".format(step_id) + + if not hasattr(flow, method): + self._progress.pop(flow.flow_id) + raise UnknownStep("Handler {} doesn't support step {}".format( + flow.__class__.__name__, step_id)) + + result = await getattr(flow, method)(user_input) + + if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_ABORT): + raise ValueError( + 'Handler returned incorrect type: {}'.format(result['type'])) + + if result['type'] == RESULT_TYPE_FORM: + flow.cur_step = (result['step_id'], result['data_schema']) + return result + + # Abort and Success results both finish the flow + self._progress.pop(flow.flow_id) + + if result['type'] == RESULT_TYPE_ABORT: + return result + + # We pass a copy of the result because we're mutating our version + result['result'] = await self._async_finish_flow(dict(result)) + return result + + +class FlowHandler: + """Handle the configuration flow of a component.""" + + # Set by flow manager + flow_id = None + hass = None + handler = None + source = SOURCE_USER + cur_step = None + + # Set by developer + VERSION = 1 + + @callback + def async_show_form(self, *, step_id, data_schema=None, errors=None): + """Return the definition of a form to gather user input.""" + return { + 'type': RESULT_TYPE_FORM, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'step_id': step_id, + 'data_schema': data_schema, + 'errors': errors, + } + + @callback + def async_create_entry(self, *, title, data): + """Finish config flow and create a config entry.""" + return { + 'version': self.VERSION, + 'type': RESULT_TYPE_CREATE_ENTRY, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'title': title, + 'data': data, + 'source': self.source, + } + + @callback + def async_abort(self, *, reason): + """Abort the config flow.""" + return { + 'type': RESULT_TYPE_ABORT, + 'flow_id': self.flow_id, + 'handler': self.handler, + 'reason': reason + } diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py new file mode 100644 index 00000000000..a8aca2fd2e9 --- /dev/null +++ b/homeassistant/helpers/data_entry_flow.py @@ -0,0 +1,106 @@ +"""Helpers for the data entry flow.""" + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator + + +def _prepare_json(result): + """Convert result for JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + elif result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class FlowManagerIndexView(HomeAssistantView): + """View to create config flows.""" + + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + + async def get(self, request): + """List flows that are in progress.""" + return self.json(self._flow_mgr.async_progress()) + + @RequestDataValidator(vol.Schema({ + vol.Required('handler'): vol.Any(str, list), + })) + async def post(self, request, data): + """Handle a POST request.""" + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init(handler) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + result = _prepare_json(result) + + return self.json(result) + + +class FlowManagerResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + def __init__(self, flow_mgr): + """Initialize the flow manager resource view.""" + self._flow_mgr = flow_mgr + + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + try: + result = await self._flow_mgr.async_configure(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + result = _prepare_json(result) + + return self.json(result) + + @RequestDataValidator(vol.Schema(dict), allow_empty=True) + async def post(self, request, flow_id, data): + """Handle a POST request.""" + try: + result = await self._flow_mgr.async_configure(flow_id, data) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + result = _prepare_json(result) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index f086437c10d..c82ae2a46f0 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -40,16 +40,7 @@ class EntityComponent(object): self.config = None self._platforms = { - domain: EntityPlatform( - hass=hass, - logger=logger, - domain=domain, - platform_name=domain, - scan_interval=self.scan_interval, - parallel_updates=0, - entity_namespace=None, - async_entities_added_callback=self._async_update_group, - ) + domain: self._async_init_entity_platform(domain, None) } self.async_add_entities = self._platforms[domain].async_add_entities self.add_entities = self._platforms[domain].add_entities @@ -102,6 +93,38 @@ class EntityComponent(object): discovery.async_listen_platform( self.hass, self.domain, component_platform_discovered) + async def async_setup_entry(self, config_entry): + """Setup a config entry.""" + platform_type = config_entry.domain + platform = await async_prepare_setup_platform( + self.hass, self.config, self.domain, platform_type) + + if platform is None: + return False + + key = config_entry.entry_id + + if key in self._platforms: + raise ValueError('Config entry has already been setup!') + + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform + ) + + return await self._platforms[key].async_setup_entry(config_entry) + + async def async_unload_entry(self, config_entry): + """Unload a config entry.""" + key = config_entry.entry_id + + platform = self._platforms.pop(key, None) + + if platform is None: + raise ValueError('Config entry was never loaded!') + + await platform.async_reset() + return True + @callback def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. @@ -127,34 +150,19 @@ class EntityComponent(object): if platform is None: return - # Config > Platform > Component - scan_interval = ( - platform_config.get(CONF_SCAN_INTERVAL) or - getattr(platform, 'SCAN_INTERVAL', None) or self.scan_interval) - parallel_updates = getattr( - platform, 'PARALLEL_UPDATES', - int(not hasattr(platform, 'async_setup_platform'))) - + # Use config scan interval, fallback to platform if none set + scan_interval = platform_config.get( + CONF_SCAN_INTERVAL, getattr(platform, 'SCAN_INTERVAL', None)) entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE) key = (platform_type, scan_interval, entity_namespace) if key not in self._platforms: - entity_platform = self._platforms[key] = EntityPlatform( - hass=self.hass, - logger=self.logger, - domain=self.domain, - platform_name=platform_type, - scan_interval=scan_interval, - parallel_updates=parallel_updates, - entity_namespace=entity_namespace, - async_entities_added_callback=self._async_update_group, + self._platforms[key] = self._async_init_entity_platform( + platform_type, platform, scan_interval, entity_namespace ) - else: - entity_platform = self._platforms[key] - await entity_platform.async_setup( - platform, platform_config, discovery_info) + await self._platforms[key].async_setup(platform_config, discovery_info) @callback def _async_update_group(self): @@ -219,3 +227,20 @@ class EntityComponent(object): await self._async_reset() return conf + + def _async_init_entity_platform(self, platform_type, platform, + scan_interval=None, entity_namespace=None): + """Helper to initialize an entity platform.""" + if scan_interval is None: + scan_interval = self.scan_interval + + return EntityPlatform( + hass=self.hass, + logger=self.logger, + domain=self.domain, + platform_name=platform_type, + platform=platform, + scan_interval=scan_interval, + entity_namespace=entity_namespace, + async_entities_added_callback=self._async_update_group, + ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 501ab5057a3..00a7e49840e 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -1,15 +1,13 @@ """Class to manage the entities for a single platform.""" import asyncio -from datetime import timedelta from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import callback, valid_entity_id, split_entity_id from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) -import homeassistant.util.dt as dt_util -from .event import async_track_time_interval, async_track_point_in_time +from .event import async_track_time_interval, async_call_later from .entity_registry import async_get_registry SLOW_SETUP_WARNING = 10 @@ -20,8 +18,8 @@ PLATFORM_NOT_READY_RETRIES = 10 class EntityPlatform(object): """Manage the entities for a single platform.""" - def __init__(self, *, hass, logger, domain, platform_name, scan_interval, - parallel_updates, entity_namespace, + def __init__(self, *, hass, logger, domain, platform_name, platform, + scan_interval, entity_namespace, async_entities_added_callback): """Initialize the entity platform. @@ -38,22 +36,81 @@ class EntityPlatform(object): self.logger = logger self.domain = domain self.platform_name = platform_name + self.platform = platform self.scan_interval = scan_interval - self.parallel_updates = None self.entity_namespace = entity_namespace self.async_entities_added_callback = async_entities_added_callback + self.config_entry = None self.entities = {} self._tasks = [] + # Method to cancel the state change listener self._async_unsub_polling = None + # Method to cancel the retry of setup + self._async_cancel_retry_setup = None self._process_updates = asyncio.Lock(loop=hass.loop) + # Platform is None for the EntityComponent "catch-all" EntityPlatform + # which powers entity_component.add_entities + if platform is None: + self.parallel_updates = None + return + + # Async platforms do all updates in parallel by default + if hasattr(platform, 'async_setup_platform'): + default_parallel_updates = 0 + else: + default_parallel_updates = 1 + + parallel_updates = getattr(platform, 'PARALLEL_UPDATES', + default_parallel_updates) + if parallel_updates: self.parallel_updates = asyncio.Semaphore( parallel_updates, loop=hass.loop) + else: + self.parallel_updates = None - async def async_setup(self, platform, platform_config, discovery_info=None, - tries=0): - """Setup the platform.""" + async def async_setup(self, platform_config, discovery_info=None): + """Setup the platform from a config file.""" + platform = self.platform + hass = self.hass + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + if getattr(platform, 'async_setup_platform', None): + return platform.async_setup_platform( + hass, platform_config, + self._async_schedule_add_entities, discovery_info + ) + + # This should not be replaced with hass.async_add_job because + # we don't want to track this task in case it blocks startup. + return hass.loop.run_in_executor( + None, platform.setup_platform, hass, platform_config, + self._schedule_add_entities, discovery_info + ) + await self._async_setup_platform(async_create_setup_task) + + async def async_setup_entry(self, config_entry): + """Setup the platform from a config entry.""" + # Store it so that we can save config entry ID in entity registry + self.config_entry = config_entry + platform = self.platform + + @callback + def async_create_setup_task(): + """Get task to setup platform.""" + return platform.async_setup_entry( + self.hass, config_entry, self._async_schedule_add_entities) + + return await self._async_setup_platform(async_create_setup_task) + + async def _async_setup_platform(self, async_create_setup_task, tries=0): + """Helper to setup a platform via config file or config entry. + + async_create_setup_task creates a coroutine that sets up platform. + """ logger = self.logger hass = self.hass full_name = '{}.{}'.format(self.domain, self.platform_name) @@ -65,18 +122,8 @@ class EntityPlatform(object): self.platform_name, SLOW_SETUP_WARNING) try: - if getattr(platform, 'async_setup_platform', None): - task = platform.async_setup_platform( - hass, platform_config, - self._async_schedule_add_entities, discovery_info - ) - else: - # This should not be replaced with hass.async_add_job because - # we don't want to track this task in case it blocks startup. - task = hass.loop.run_in_executor( - None, platform.setup_platform, hass, platform_config, - self._schedule_add_entities, discovery_info - ) + task = async_create_setup_task() + await asyncio.wait_for( asyncio.shield(task, loop=hass.loop), SLOW_SETUP_MAX_WAIT, loop=hass.loop) @@ -91,24 +138,33 @@ class EntityPlatform(object): pending, loop=self.hass.loop) hass.config.components.add(full_name) + return True except PlatformNotReady: tries += 1 wait_time = min(tries, 6) * 30 logger.warning( 'Platform %s not ready yet. Retrying in %d seconds.', self.platform_name, wait_time) - async_track_point_in_time( - hass, self.async_setup( - platform, platform_config, discovery_info, tries), - dt_util.utcnow() + timedelta(seconds=wait_time)) + + async def setup_again(now): + """Run setup again.""" + self._async_cancel_retry_setup = None + await self._async_setup_platform( + async_create_setup_task, tries) + + self._async_cancel_retry_setup = \ + async_call_later(hass, wait_time, setup_again) + return False except asyncio.TimeoutError: logger.error( "Setup of platform %s is taking longer than %s seconds." " Startup will proceed without waiting any longer.", self.platform_name, SLOW_SETUP_MAX_WAIT) + return False except Exception: # pylint: disable=broad-except logger.exception( "Error while setting up platform %s", self.platform_name) + return False finally: warn_task.cancel() @@ -264,6 +320,10 @@ class EntityPlatform(object): This method must be run in the event loop. """ + if self._async_cancel_retry_setup is not None: + self._async_cancel_retry_setup() + self._async_cancel_retry_setup = None + if not self.entities: return diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 353fda28875..3a24de6b39c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -452,6 +452,38 @@ def logarithm(value, base=math.e): return value +def sine(value): + """Filter to get sine of the value.""" + try: + return math.sin(float(value)) + except (ValueError, TypeError): + return value + + +def cosine(value): + """Filter to get cosine of the value.""" + try: + return math.cos(float(value)) + except (ValueError, TypeError): + return value + + +def tangent(value): + """Filter to get tangent of the value.""" + try: + return math.tan(float(value)) + except (ValueError, TypeError): + return value + + +def square_root(value): + """Filter to get square root of the value.""" + try: + return math.sqrt(float(value)) + except (ValueError, TypeError): + return value + + def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True): """Filter to convert given timestamp to format.""" try: @@ -571,6 +603,10 @@ ENV = TemplateEnvironment() ENV.filters['round'] = forgiving_round ENV.filters['multiply'] = multiply ENV.filters['log'] = logarithm +ENV.filters['sin'] = sine +ENV.filters['cos'] = cosine +ENV.filters['tan'] = tangent +ENV.filters['sqrt'] = square_root ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc @@ -583,6 +619,13 @@ ENV.filters['regex_replace'] = regex_replace ENV.filters['regex_search'] = regex_search ENV.filters['regex_findall_index'] = regex_findall_index ENV.globals['log'] = logarithm +ENV.globals['sin'] = sine +ENV.globals['cos'] = cosine +ENV.globals['tan'] = tangent +ENV.globals['sqrt'] = square_root +ENV.globals['pi'] = math.pi +ENV.globals['tau'] = math.pi * 2 +ENV.globals['e'] = math.e ENV.globals['float'] = forgiving_float ENV.globals['now'] = dt_util.now ENV.globals['utcnow'] = dt_util.utcnow diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 85f8d5dcf12..6de885942fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.1 +aiohttp==3.1.3 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 diff --git a/requirements_all.txt b/requirements_all.txt index 36a8f30502f..aeb5b84811e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -6,7 +6,7 @@ pip>=8.0.3 jinja2>=2.10 voluptuous==0.11.1 typing>=3,<4 -aiohttp==3.1.1 +aiohttp==3.1.3 async_timeout==2.0.1 astral==1.6 certifi>=2017.4.17 @@ -15,6 +15,12 @@ attrs==17.4.0 # homeassistant.components.nuimo_controller --only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 +# homeassistant.components.sensor.sht31 +Adafruit-GPIO==1.0.3 + +# homeassistant.components.sensor.sht31 +Adafruit-SHT31==1.0.2 + # homeassistant.components.bbb_gpio # Adafruit_BBIO==1.0.0 @@ -22,7 +28,7 @@ attrs==17.4.0 DoorBirdPy==0.1.3 # homeassistant.components.homekit -HAP-python==1.1.8 +HAP-python==1.1.9 # homeassistant.components.notify.mastodon Mastodon.py==1.2.2 @@ -64,7 +70,7 @@ WazeRouteCalculator==0.5 YesssSMS==0.1.1b3 # homeassistant.components.abode -abodepy==0.12.3 +abodepy==0.13.1 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.3 @@ -98,7 +104,7 @@ aiopvapi==1.5.4 alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage -alpha_vantage==1.9.0 +alpha_vantage==2.0.0 # homeassistant.components.amcrest amcrest==1.2.2 @@ -131,7 +137,7 @@ basicmodem==0.7 batinfo==0.4.2 # homeassistant.components.sensor.eddystone_temperature -# beacontools[scan]==1.2.1 +# beacontools[scan]==1.2.3 # homeassistant.components.device_tracker.linksys_ap # homeassistant.components.sensor.geizhals @@ -140,7 +146,7 @@ batinfo==0.4.2 beautifulsoup4==4.6.0 # homeassistant.components.zha -bellows==0.5.1 +bellows==0.5.2 # homeassistant.components.bmw_connected_drive bimmer_connected==0.5.0 @@ -203,6 +209,7 @@ colorlog==3.1.2 concord232==0.15 # homeassistant.components.climate.eq3btsmart +# homeassistant.components.device_tracker.xiaomi_miio # homeassistant.components.fan.xiaomi_miio # homeassistant.components.light.xiaomi_miio # homeassistant.components.remote.xiaomi_miio @@ -311,7 +318,7 @@ fixerio==0.1.1 flux_led==0.21 # homeassistant.components.sensor.foobot -foobot_async==0.3.0 +foobot_async==0.3.1 # homeassistant.components.notify.free_mobile freesms==0.1.2 @@ -379,7 +386,10 @@ hipnotify==1.0.8 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180404.0 +home-assistant-frontend==20180420.0 + +# homeassistant.components.homekit_controller +# homekit==0.5 # homeassistant.components.homematicip_cloud homematicip==0.8 @@ -454,6 +464,9 @@ keyring==12.0.0 # homeassistant.scripts.keyring keyrings.alt==3.0 +# homeassistant.components.eufy +lakeside==0.5 + # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http libnacl==1.6.1 @@ -490,7 +503,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==0.4.0 +locationsharinglib==1.2.1 # homeassistant.components.sensor.luftdaten luftdaten==0.1.3 @@ -512,10 +525,7 @@ messagebird==1.2.0 mficlient==0.3.0 # homeassistant.components.sensor.miflora -miflora==0.3.0 - -# homeassistant.components.upnp -miniupnpc==2.0.2 +miflora==0.4.0 # homeassistant.components.sensor.mopar motorparts==1.0.2 @@ -540,7 +550,7 @@ nad_receiver==0.0.9 nanoleaf==0.4.1 # homeassistant.components.discovery -netdisco==1.3.0 +netdisco==1.3.1 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -670,7 +680,7 @@ pyHS100==0.3.0 pyRFXtrx==0.22.0 # homeassistant.components.sensor.tibber -pyTibber==0.4.0 +pyTibber==0.4.1 # homeassistant.components.switch.dlink pyW215==0.6.0 @@ -700,6 +710,9 @@ pyatv==0.3.9 # homeassistant.components.sensor.bbox pybbox==0.0.5-alpha +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 @@ -726,7 +739,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==35 +pydeconz==36 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -734,9 +747,6 @@ pydispatcher==2.0.5 # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 -# homeassistant.components.sensor.ebox -pyebox==0.1.0 - # homeassistant.components.climate.econet pyeconet==0.0.5 @@ -761,6 +771,9 @@ pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.fritzbox +pyfritzhome==0.3.7 + # homeassistant.components.ifttt pyfttt==0.3 @@ -774,10 +787,10 @@ pyharmony==1.0.20 pyhik==0.1.8 # homeassistant.components.hive -pyhiveapi==0.2.11 +pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.40 +pyhomematic==0.1.41 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -820,7 +833,7 @@ pylitejet==0.1 pyloopenergy==0.0.18 # homeassistant.components.lutron_caseta -pylutron-caseta==0.3.0 +pylutron-caseta==0.5.0 # homeassistant.components.lutron pylutron==0.1.0 @@ -829,7 +842,7 @@ pylutron==0.1.0 pymailgunner==1.4 # homeassistant.components.media_player.mediaroom -pymediaroom==0.6 +pymediaroom==0.6.3 # homeassistant.components.media_player.xiaomi_tv pymitv==1.0.0 @@ -885,7 +898,7 @@ pyowm==2.8.0 pypollencom==1.1.2 # homeassistant.components.qwikswitch -pyqwikswitch==0.6 +pyqwikswitch==0.71 # homeassistant.components.rainbird pyrainbird==0.1.3 @@ -939,7 +952,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.0.17 +python-ecobee-api==0.0.18 # homeassistant.components.climate.eq3btsmart # python-eq3bt==0.1.9 @@ -977,7 +990,7 @@ python-juicenet==0.0.5 python-miio==0.3.9 # homeassistant.components.media_player.mpd -python-mpd2==0.5.5 +python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom @@ -1052,6 +1065,9 @@ pytradfri[async]==5.4.2 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.1 + # homeassistant.components.keyboard # pyuserinput==0.1.11 @@ -1159,7 +1175,7 @@ simplepush==1.1.4 simplisafe-python==1.0.5 # homeassistant.components.skybell -skybellpy==0.1.1 +skybellpy==0.1.2 # homeassistant.components.notify.slack slacker==0.9.65 @@ -1196,7 +1212,7 @@ spotcrime==1.0.3 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.5 +sqlalchemy==1.2.6 # homeassistant.components.statsd statsd==3.2.1 @@ -1262,6 +1278,9 @@ upcloud-api==0.4.2 # homeassistant.components.sensor.ups upsmychoice==1.0.6 +# homeassistant.components.sensor.uscis +uscisstatus==0.1.1 + # homeassistant.components.camera.uvc uvcclient==0.10.1 @@ -1339,7 +1358,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.04.03 +youtube_dl==2018.04.16 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e44b0dc85d5..0d371996e36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -19,7 +19,7 @@ requests_mock==1.4 # homeassistant.components.homekit -HAP-python==1.1.8 +HAP-python==1.1.9 # homeassistant.components.notify.html5 PyJWT==1.6.0 @@ -63,7 +63,7 @@ evohomeclient==0.2.5 feedparser==5.2.1 # homeassistant.components.sensor.foobot -foobot_async==0.3.0 +foobot_async==0.3.1 # homeassistant.components.tts.google gTTS-token==1.1.1 @@ -81,7 +81,7 @@ hbmqtt==0.9.1 holidays==0.9.4 # homeassistant.components.frontend -home-assistant-frontend==20180404.0 +home-assistant-frontend==20180420.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -129,8 +129,11 @@ pushbullet.py==0.11.0 # homeassistant.components.canary py-canary==0.5.0 +# homeassistant.components.media_player.blackbird +pyblackbird==0.5 + # homeassistant.components.deconz -pydeconz==35 +pydeconz==36 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -145,6 +148,9 @@ pymonoprice==0.3 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.qwikswitch +pyqwikswitch==0.71 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky python-forecastio==1.4.0 @@ -155,6 +161,9 @@ pythonwhois==2.4.3 # homeassistant.components.device_tracker.unifi pyunifi==2.13 +# homeassistant.components.upnp +pyupnp-async==0.1.0.1 + # homeassistant.components.notify.html5 pywebpush==1.6.0 @@ -179,7 +188,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.5 +sqlalchemy==1.2.6 # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d5bb2701e9b..b5b636dc874 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -33,6 +33,7 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'credstash', 'bme680', + 'homekit', ) TEST_REQUIREMENTS = ( @@ -67,14 +68,17 @@ TEST_REQUIREMENTS = ( 'prometheus_client', 'pushbullet.py', 'py-canary', + 'pyblackbird', 'pydeconz', 'pydispatcher', 'PyJWT', 'pylitejet', 'pymonoprice', 'pynx584', + 'pyqwikswitch', 'python-forecastio', 'pyunifi', + 'pyupnp-async', 'pywebpush', 'restrictedpython', 'rflink', diff --git a/script/lazytox.py b/script/lazytox.py index 2639d640753..19af5560dfb 100755 --- a/script/lazytox.py +++ b/script/lazytox.py @@ -18,7 +18,7 @@ except ImportError: RE_ASCII = re.compile(r"\033\[[^m]*m") -Error = namedtuple('Error', ['file', 'line', 'col', 'msg']) +Error = namedtuple('Error', ['file', 'line', 'col', 'msg', 'skip']) PASS = 'green' FAIL = 'bold_red' @@ -109,8 +109,9 @@ async def pylint(files): line = line.split(':') if len(line) < 3: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], "", line[2].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error( + _fn, line[1], '', line[2].strip(), _fn.startswith('tests/'))) return res @@ -122,8 +123,8 @@ async def flake8(files): line = line.split(':') if len(line) < 4: continue - res.append(Error(line[0].replace('\\', '/'), - line[1], line[2], line[3].strip())) + _fn = line[0].replace('\\', '/') + res.append(Error(_fn, line[1], line[2], line[3].strip(), False)) return res @@ -144,7 +145,7 @@ async def lint(files): err_msg = "{} {}:{} {}".format(err.file, err.line, err.col, err.msg) # tests/* does not have to pass lint - if err.file.startswith('tests/'): + if err.skip: print(err_msg) else: printc(FAIL, err_msg) diff --git a/script/translations_upload b/script/translations_upload index 578cc8c0ccf..5bf9fe1e121 100755 --- a/script/translations_upload +++ b/script/translations_upload @@ -35,9 +35,10 @@ script/translations_upload_merge.py docker run \ -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 lokalise \ --token ${LOKALISE_TOKEN} \ import ${PROJECT_ID} \ --file /opt/src/${LOCAL_FILE} \ --lang_iso ${LANG_ISO} \ + --convert_placeholders 0 \ --replace 1 diff --git a/setup.py b/setup.py index db4b1f8df92..8815b0227ad 100755 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ REQUIRES = [ 'jinja2>=2.10', 'voluptuous==0.11.1', 'typing>=3,<4', - 'aiohttp==3.1.1', + 'aiohttp==3.1.3', 'async_timeout==2.0.1', 'astral==1.6', 'certifi>=2017.4.17', diff --git a/tests/common.py b/tests/common.py index bc84b3493a8..67fd8bab23f 100644 --- a/tests/common.py +++ b/tests/common.py @@ -10,7 +10,7 @@ import logging import threading from contextlib import contextmanager -from homeassistant import core as ha, loader, config_entries +from homeassistant import core as ha, loader, data_entry_flow, config_entries from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -344,7 +344,8 @@ class MockPlatform(object): # pylint: disable=invalid-name def __init__(self, setup_platform=None, dependencies=None, - platform_schema=None, async_setup_platform=None): + platform_schema=None, async_setup_platform=None, + async_setup_entry=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] @@ -358,6 +359,9 @@ class MockPlatform(object): if async_setup_platform is not None: self.async_setup_platform = async_setup_platform + if async_setup_entry is not None: + self.async_setup_entry = async_setup_entry + if setup_platform is None and async_setup_platform is None: self.async_setup_platform = mock_coro_func() @@ -370,19 +374,27 @@ class MockEntityPlatform(entity_platform.EntityPlatform): logger=None, domain='test_domain', platform_name='test_platform', + platform=None, scan_interval=timedelta(seconds=15), - parallel_updates=0, entity_namespace=None, async_entities_added_callback=lambda: None ): """Initialize a mock entity platform.""" + if logger is None: + logger = logging.getLogger('homeassistant.helpers.entity_platform') + + # Otherwise the constructor will blow up. + if (isinstance(platform, Mock) and + isinstance(platform.PARALLEL_UPDATES, Mock)): + platform.PARALLEL_UPDATES = 0 + super().__init__( hass=hass, logger=logger, domain=domain, platform_name=platform_name, + platform=platform, scan_interval=scan_interval, - parallel_updates=parallel_updates, entity_namespace=entity_namespace, async_entities_added_callback=async_entities_added_callback, ) @@ -443,7 +455,7 @@ class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" def __init__(self, *, domain='test', data=None, version=0, entry_id=None, - source=config_entries.SOURCE_USER, title='Mock Title', + source=data_entry_flow.SOURCE_USER, title='Mock Title', state=None): """Initialize a mock config entry.""" kwargs = { diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index dd404b7d57a..afa4d19b5d9 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -807,15 +807,23 @@ async def test_thermostat(hass): 'Alexa.ThermostatController', 'SetThermostatMode', 'climate#test_thermostat', 'climate.set_operation_mode', hass, - payload={'thermostatMode': 'HEAT'} + payload={'thermostatMode': {'value': 'HEAT'}} ) assert call.data['operation_mode'] == 'heat' + call, _ = await assert_request_calls_service( + 'Alexa.ThermostatController', 'SetThermostatMode', + 'climate#test_thermostat', 'climate.set_operation_mode', + hass, + payload={'thermostatMode': 'HEAT'} + ) + + assert call.data['operation_mode'] == 'heat' msg = await assert_request_fails( 'Alexa.ThermostatController', 'SetThermostatMode', 'climate#test_thermostat', 'climate.set_operation_mode', hass, - payload={'thermostatMode': 'INVALID'} + payload={'thermostatMode': {'value': 'INVALID'}} ) assert msg['event']['payload']['type'] == 'UNSUPPORTED_THERMOSTAT_MODE' diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index cfe6b12baac..f53be8818a3 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -8,7 +8,8 @@ import pytest import voluptuous as vol from homeassistant import config_entries as core_ce -from homeassistant.config_entries import ConfigFlowHandler, HANDLERS +from homeassistant.config_entries import HANDLERS +from homeassistant.data_entry_flow import FlowHandler from homeassistant.setup import async_setup_component from homeassistant.components.config import config_entries from homeassistant.loader import set_component @@ -16,6 +17,12 @@ from homeassistant.loader import set_component from tests.common import MockConfigEntry, MockModule, mock_coro_func +@pytest.fixture(scope='session', autouse=True) +def mock_test_component(): + """Ensure a component called 'test' exists.""" + set_component('test', MockModule('test')) + + @pytest.fixture def client(hass, aiohttp_client): """Fixture that can interact with the config manager API.""" @@ -93,7 +100,7 @@ def test_available_flows(hass, client): @asyncio.coroutine def test_initialize_flow(hass, client): """Test we can initialize a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() @@ -110,7 +117,7 @@ def test_initialize_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() @@ -119,7 +126,7 @@ def test_initialize_flow(hass, client): assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'init', 'data_schema': [ { @@ -142,20 +149,20 @@ def test_initialize_flow(hass, client): @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): return self.async_abort(reason='bla') with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'reason': 'bla', 'type': 'abort' } @@ -167,7 +174,7 @@ def test_create_account(hass, client): set_component( 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -179,15 +186,17 @@ def test_create_account(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'title': 'Test Entry', - 'type': 'create_entry' + 'type': 'create_entry', + 'source': 'user', + 'version': 1, } @@ -197,7 +206,7 @@ def test_two_step_flow(hass, client): set_component( 'test', MockModule('test', async_setup_entry=mock_coro_func(True))) - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine @@ -217,13 +226,13 @@ def test_two_step_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() flow_id = data.pop('flow_id') assert data == { 'type': 'form', - 'domain': 'test', + 'handler': 'test', 'step_id': 'account', 'data_schema': [ { @@ -242,16 +251,18 @@ def test_two_step_flow(hass, client): data = yield from resp.json() data.pop('flow_id') assert data == { - 'domain': 'test', + 'handler': 'test', 'type': 'create_entry', 'title': 'user-title', + 'version': 1, + 'source': 'user', } @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): VERSION = 5 @asyncio.coroutine @@ -274,7 +285,7 @@ def test_get_progress_index(hass, client): assert data == [ { 'flow_id': form['flow_id'], - 'domain': 'test', + 'handler': 'test', 'source': 'hassio' } ] @@ -283,7 +294,7 @@ def test_get_progress_index(hass, client): @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" - class TestFlow(ConfigFlowHandler): + class TestFlow(FlowHandler): @asyncio.coroutine def async_step_init(self, user_input=None): schema = OrderedDict() @@ -300,7 +311,7 @@ def test_get_progress_flow(hass, client): with patch.dict(HANDLERS, {'test': TestFlow}): resp = yield from client.post('/api/config/config_entries/flow', - json={'domain': 'test'}) + json={'handler': 'test'}) assert resp.status == 200 data = yield from resp.json() diff --git a/tests/components/deconz/__init__.py b/tests/components/deconz/__init__.py new file mode 100644 index 00000000000..59b903e8900 --- /dev/null +++ b/tests/components/deconz/__init__.py @@ -0,0 +1 @@ +"""Tests for the deCONZ component.""" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py new file mode 100644 index 00000000000..d86475b35ef --- /dev/null +++ b/tests/components/deconz/test_config_flow.py @@ -0,0 +1,225 @@ +"""Tests for deCONZ config flow.""" +from unittest.mock import patch +import pytest + +import voluptuous as vol +from homeassistant.components.deconz import config_flow +from tests.common import MockConfigEntry + +import pydeconz + + +async def test_flow_works(hass, aioclient_mock): + """Test that config flow works.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + aioclient_mock.post('http://1.2.3.4:80/api', json=[ + {"success": {"username": "1234567890ABCDEF"}} + ]) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + await flow.async_step_init() + result = await flow.async_step_link(user_input={}) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } + + +async def test_flow_already_registered_bridge(hass): + """Test config flow don't allow more than one bridge to be registered.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_init() + assert result['type'] == 'abort' + + +async def test_flow_no_discovered_bridges(hass, aioclient_mock): + """Test config flow discovers no bridges.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() + 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(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + 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(pydeconz.utils.URL_DISCOVER, json=[ + {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': 80}, + {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': 80} + ]) + flow = config_flow.DeconzFlowHandler() + 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_link_no_api_key(hass, aioclient_mock): + """Test config flow should abort if no API key was possible to retrieve.""" + aioclient_mock.post('http://1.2.3.4:80/api', json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'no_key'} + + +async def test_link_already_registered_bridge(hass): + """Test that link verifies to only allow one config entry to complete. + + This is possible with discovery which will allow the user to complete + a second config entry and then complete the discovered config entry. + """ + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_link(user_input={}) + assert result['type'] == 'abort' + + +async def test_bridge_discovery(hass): + """Test a bridge being discovered with no additional config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', return_value={}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_config_file(hass): + """Test a bridge being discovered with a corresponding config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '1.2.3.4', + 'port': 8080, + 'api_key': '1234567890ABCDEF'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } + + +async def test_bridge_discovery_other_config_file(hass): + """Test a bridge being discovered with another bridges config file.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + with patch.object(config_flow, 'load_json', + return_value={'host': '5.6.7.8', 'api_key': '5678'}): + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'port': 80, + 'serial': 'id' + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_bridge_discovery_already_configured(hass): + """Test if a discovered bridge has already been configured.""" + MockConfigEntry(domain='deconz', data={ + 'host': '1.2.3.4' + }).add_to_hass(hass) + + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery({ + 'host': '1.2.3.4', + 'serial': 'id' + }) + + assert result['type'] == 'abort' + + +async def test_import_without_api_key(hass): + """Test importing a host without an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'host': '1.2.3.4', + }) + + assert result['type'] == 'form' + assert result['step_id'] == 'link' + + +async def test_import_with_api_key(hass): + """Test importing a host with an API key.""" + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF' + } diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py new file mode 100644 index 00000000000..cbc8a373972 --- /dev/null +++ b/tests/components/deconz/test_init.py @@ -0,0 +1,69 @@ +"""Test deCONZ component setup process.""" +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import deconz + + +async def test_config_with_host_passed_to_config_entry(hass): + """Test that configured options for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_file_passed_to_config_entry(hass): + """Test that configuration file for a host are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', + return_value={'host': '1.2.3.4'}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # Import flow started + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_without_host_not_passed_to_config_entry(hass): + """Test that a configuration without a host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', return_value=[]), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: {} + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered host does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(deconz, 'configured_hosts', + return_value=['1.2.3.4']), \ + patch.object(deconz, 'load_json', return_value={}): + assert await async_setup_component(hass, deconz.DOMAIN, { + deconz.DOMAIN: { + deconz.CONF_HOST: '1.2.3.4', + deconz.CONF_PORT: 80 + } + }) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_discovery(hass): + """Test that a discovered bridge does not initiate an import.""" + with patch.object(hass, 'config_entries') as mock_config_entries: + assert await async_setup_component(hass, deconz.DOMAIN, {}) is True + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index a2facd826e4..f8e026483aa 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -2,21 +2,66 @@ This includes tests for all mock object types. """ +from datetime import datetime, timedelta import unittest from unittest.mock import call, patch, Mock from homeassistant.components.homekit.accessories import ( add_preload_service, set_accessory_info, - HomeAccessory, HomeBridge, HomeDriver) + debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( - ACCESSORY_MODEL, ACCESSORY_NAME, BRIDGE_MODEL, BRIDGE_NAME, - SERV_ACCESSORY_INFO, CHAR_MANUFACTURER, CHAR_MODEL, - CHAR_NAME, CHAR_SERIAL_NUMBER) + BRIDGE_MODEL, BRIDGE_NAME, SERV_ACCESSORY_INFO, + CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER) +from homeassistant.const import ATTR_NOW, EVENT_TIME_CHANGED +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant + + +def patch_debounce(): + """Return patch for debounce method.""" + return patch('homeassistant.components.homekit.accessories.debounce', + lambda f: lambda *args, **kwargs: f(*args, **kwargs)) class TestAccessories(unittest.TestCase): """Test pyhap adapter methods.""" + def test_debounce(self): + """Test add_timeout decorator function.""" + def demo_func(*args): + nonlocal arguments, counter + counter += 1 + arguments = args + + arguments = None + counter = 0 + hass = get_test_home_assistant() + mock = Mock(hass=hass) + + debounce_demo = debounce(demo_func) + self.assertEqual(debounce_demo.__name__, 'demo_func') + now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC) + + with patch('homeassistant.util.dt.utcnow', return_value=now): + debounce_demo(mock, 'value') + hass.bus.fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + hass.block_till_done() + assert counter == 1 + assert len(arguments) == 2 + + with patch('homeassistant.util.dt.utcnow', return_value=now): + debounce_demo(mock, 'value') + debounce_demo(mock, 'value') + + hass.bus.fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + hass.block_till_done() + assert counter == 2 + + hass.stop() + def test_add_preload_service(self): """Test add_preload_service without additional characteristics.""" acc = Mock() @@ -46,7 +91,7 @@ class TestAccessories(unittest.TestCase): def test_set_accessory_info(self): """Test setting the basic accessory information.""" # Test HomeAccessory - acc = HomeAccessory() + acc = HomeAccessory('HA', 'Home Accessory', 'homekit.accessory', 2, '') set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') serv = acc.get_service(SERV_ACCESSORY_INFO) @@ -58,7 +103,7 @@ class TestAccessories(unittest.TestCase): serv.get_characteristic(CHAR_SERIAL_NUMBER).value, '0000') # Test HomeBridge - acc = HomeBridge(None) + acc = HomeBridge('hass') set_accessory_info(acc, 'name', 'model', 'manufacturer', '0000') serv = acc.get_service(SERV_ACCESSORY_INFO) @@ -70,26 +115,37 @@ class TestAccessories(unittest.TestCase): def test_home_accessory(self): """Test HomeAccessory class.""" - acc = HomeAccessory() - self.assertEqual(acc.display_name, ACCESSORY_NAME) + hass = get_test_home_assistant() + + acc = HomeAccessory(hass, 'Home Accessory', 'homekit.accessory', 2, '') + self.assertEqual(acc.hass, hass) + self.assertEqual(acc.display_name, 'Home Accessory') self.assertEqual(acc.category, 1) # Category.OTHER self.assertEqual(len(acc.services), 1) serv = acc.services[0] # SERV_ACCESSORY_INFO self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, ACCESSORY_MODEL) + serv.get_characteristic(CHAR_MODEL).value, 'homekit.accessory') - acc = HomeAccessory('test_name', 'test_model', 'FAN', aid=2) + hass.states.set('homekit.accessory', 'on') + hass.block_till_done() + acc.run() + hass.states.set('homekit.accessory', 'off') + hass.block_till_done() + + acc = HomeAccessory('hass', 'test_name', 'test_model', 2, '') self.assertEqual(acc.display_name, 'test_name') - self.assertEqual(acc.category, 3) # Category.FAN self.assertEqual(acc.aid, 2) self.assertEqual(len(acc.services), 1) serv = acc.services[0] # SERV_ACCESSORY_INFO self.assertEqual( serv.get_characteristic(CHAR_MODEL).value, 'test_model') + hass.stop() + def test_home_bridge(self): """Test HomeBridge class.""" - bridge = HomeBridge(None) + bridge = HomeBridge('hass') + self.assertEqual(bridge.hass, 'hass') self.assertEqual(bridge.display_name, BRIDGE_NAME) self.assertEqual(bridge.category, 2) # Category.BRIDGE self.assertEqual(len(bridge.services), 1) @@ -98,12 +154,10 @@ class TestAccessories(unittest.TestCase): self.assertEqual( serv.get_characteristic(CHAR_MODEL).value, BRIDGE_MODEL) - bridge = HomeBridge('hass', 'test_name', 'test_model') + bridge = HomeBridge('hass', 'test_name') self.assertEqual(bridge.display_name, 'test_name') self.assertEqual(len(bridge.services), 1) serv = bridge.services[0] # SERV_ACCESSORY_INFO - self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, 'test_model') # setup_message bridge.setup_message() @@ -128,11 +182,11 @@ class TestAccessories(unittest.TestCase): self.assertEqual( mock_remove_paired_client.call_args, call('client_uuid')) - self.assertEqual(mock_show_msg.call_args, call(bridge, 'hass')) + self.assertEqual(mock_show_msg.call_args, call('hass', bridge)) def test_home_driver(self): """Test HomeDriver class.""" - bridge = HomeBridge(None) + bridge = HomeBridge('hass') ip_address = '127.0.0.1' port = 51826 path = '.homekit.state' diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e29ed85b5fc..c26982e170b 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -4,12 +4,14 @@ import unittest from unittest.mock import patch, Mock from homeassistant.core import State +from homeassistant.components.cover import ( + SUPPORT_OPEN, SUPPORT_CLOSE) from homeassistant.components.climate import ( SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.homekit import get_accessory, TYPES from homeassistant.const import ( ATTR_CODE, ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_DEVICE_CLASS) _LOGGER = logging.getLogger(__name__) @@ -19,14 +21,14 @@ CONFIG = {} def test_get_accessory_invalid_aid(caplog): """Test with unsupported component.""" assert get_accessory(None, State('light.demo', 'on'), - aid=None, config=None) is None + None, config=None) is None assert caplog.records[0].levelname == 'WARNING' assert 'invalid aid' in caplog.records[0].msg def test_not_supported(): """Test if none is returned if entity isn't supported.""" - assert get_accessory(None, State('demo.demo', 'on'), aid=2, config=None) \ + assert get_accessory(None, State('demo.demo', 'on'), 2, config=None) \ is None @@ -41,6 +43,13 @@ class TestGetAccessories(unittest.TestCase): """Test if mock type was called.""" self.assertTrue(self.mock_type.called) + def test_sensor_temperature(self): + """Test temperature sensor with device class temperature.""" + with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): + state = State('sensor.temperature', '23', + {ATTR_DEVICE_CLASS: 'temperature'}) + get_accessory(None, state, 2, {}) + def test_sensor_temperature_celsius(self): """Test temperature sensor with Celsius as unit.""" with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): @@ -48,7 +57,6 @@ class TestGetAccessories(unittest.TestCase): {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) get_accessory(None, state, 2, {}) - # pylint: disable=invalid-name def test_sensor_temperature_fahrenheit(self): """Test temperature sensor with Fahrenheit as unit.""" with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): @@ -57,12 +65,88 @@ class TestGetAccessories(unittest.TestCase): get_accessory(None, state, 2, {}) def test_sensor_humidity(self): + """Test humidity sensor with device class humidity.""" + with patch.dict(TYPES, {'HumiditySensor': self.mock_type}): + state = State('sensor.humidity', '20', + {ATTR_DEVICE_CLASS: 'humidity'}) + get_accessory(None, state, 2, {}) + + def test_sensor_humidity_unit(self): """Test humidity sensor with % as unit.""" with patch.dict(TYPES, {'HumiditySensor': self.mock_type}): state = State('sensor.humidity', '20', {ATTR_UNIT_OF_MEASUREMENT: '%'}) get_accessory(None, state, 2, {}) + def test_air_quality_sensor(self): + """Test air quality sensor with pm25 class.""" + with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): + state = State('sensor.air_quality', '40', + {ATTR_DEVICE_CLASS: 'pm25'}) + get_accessory(None, state, 2, {}) + + def test_air_quality_sensor_entity_id(self): + """Test air quality sensor with entity_id contains pm25.""" + with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): + state = State('sensor.air_quality_pm25', '40', {}) + get_accessory(None, state, 2, {}) + + def test_co2_sensor(self): + """Test co2 sensor with device class co2.""" + with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): + state = State('sensor.airmeter', '500', + {ATTR_DEVICE_CLASS: 'co2'}) + get_accessory(None, state, 2, {}) + + def test_co2_sensor_entity_id(self): + """Test co2 sensor with entity_id contains co2.""" + with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): + state = State('sensor.airmeter_co2', '500', {}) + get_accessory(None, state, 2, {}) + + def test_light_sensor(self): + """Test light sensor with device class lux.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_DEVICE_CLASS: 'light'}) + get_accessory(None, state, 2, {}) + + def test_light_sensor_unit_lm(self): + """Test light sensor with lm as unit.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_UNIT_OF_MEASUREMENT: 'lm'}) + get_accessory(None, state, 2, {}) + + def test_light_sensor_unit_lux(self): + """Test light sensor with lux as unit.""" + with patch.dict(TYPES, {'LightSensor': self.mock_type}): + state = State('sensor.light', '900', + {ATTR_UNIT_OF_MEASUREMENT: 'lux'}) + get_accessory(None, state, 2, {}) + + def test_binary_sensor(self): + """Test binary sensor with opening class.""" + with patch.dict(TYPES, {'BinarySensor': self.mock_type}): + state = State('binary_sensor.opening', 'on', + {ATTR_DEVICE_CLASS: 'opening'}) + get_accessory(None, state, 2, {}) + + def test_device_tracker(self): + """Test binary sensor with opening class.""" + with patch.dict(TYPES, {'BinarySensor': self.mock_type}): + state = State('device_tracker.someone', 'not_home', {}) + get_accessory(None, state, 2, {}) + + def test_garage_door(self): + """Test cover with device_class: 'garage' and required features.""" + with patch.dict(TYPES, {'GarageDoorOpener': self.mock_type}): + state = State('cover.garage_door', 'open', { + ATTR_DEVICE_CLASS: 'garage', + ATTR_SUPPORTED_FEATURES: + SUPPORT_OPEN | SUPPORT_CLOSE}) + get_accessory(None, state, 2, {}) + def test_cover_set_position(self): """Test cover with support for set_cover_position.""" with patch.dict(TYPES, {'WindowCovering': self.mock_type}): @@ -70,6 +154,13 @@ class TestGetAccessories(unittest.TestCase): {ATTR_SUPPORTED_FEATURES: 4}) get_accessory(None, state, 2, {}) + def test_cover_open_close(self): + """Test cover with support for open and close.""" + with patch.dict(TYPES, {'WindowCoveringBasic': self.mock_type}): + state = State('cover.open_window', 'open', + {ATTR_SUPPORTED_FEATURES: 3}) + get_accessory(None, state, 2, {}) + def test_alarm_control_panel(self): """Test alarm control panel.""" config = {ATTR_CODE: '1234'} @@ -78,8 +169,9 @@ class TestGetAccessories(unittest.TestCase): get_accessory(None, state, 2, config) # pylint: disable=unsubscriptable-object + print(self.mock_type.call_args[1]) self.assertEqual( - self.mock_type.call_args[1].get('alarm_code'), '1234') + self.mock_type.call_args[1]['config'][ATTR_CODE], '1234') def test_climate(self): """Test climate devices.""" @@ -87,10 +179,6 @@ class TestGetAccessories(unittest.TestCase): state = State('climate.test', 'auto') get_accessory(None, state, 2, {}) - # pylint: disable=unsubscriptable-object - self.assertEqual( - self.mock_type.call_args[0][-1], False) # support_auto - def test_light(self): """Test light devices.""" with patch.dict(TYPES, {'Light': self.mock_type}): @@ -106,10 +194,6 @@ class TestGetAccessories(unittest.TestCase): SUPPORT_TARGET_TEMPERATURE_HIGH}) get_accessory(None, state, 2, {}) - # pylint: disable=unsubscriptable-object - self.assertEqual( - self.mock_type.call_args[0][-1], True) # support_auto - def test_switch(self): """Test switch.""" with patch.dict(TYPES, {'Switch': self.mock_type}): @@ -127,3 +211,9 @@ class TestGetAccessories(unittest.TestCase): with patch.dict(TYPES, {'Switch': self.mock_type}): state = State('input_boolean.test', 'on') get_accessory(None, state, 2, {}) + + def test_lock(self): + """Test lock.""" + with patch.dict(TYPES, {'Lock': self.mock_type}): + state = State('lock.test', 'locked') + get_accessory(None, state, 2, {}) diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index c6d79545487..d1ad232d279 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce IP_ADDRESS = '127.0.0.1' PATH_HOMEKIT = 'homeassistant.components.homekit' @@ -22,6 +23,17 @@ PATH_HOMEKIT = 'homeassistant.components.homekit' class TestHomeKit(unittest.TestCase): """Test setup of HomeKit component and HomeKit class.""" + @classmethod + def setUpClass(cls): + """Setup debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() @@ -161,7 +173,7 @@ class TestHomeKit(unittest.TestCase): self.assertEqual(mock_add_bridge_acc.mock_calls, [call(state)]) self.assertEqual(mock_show_setup_msg.mock_calls, [ - call(homekit.bridge, self.hass)]) + call(self.hass, homekit.bridge)]) self.assertEqual(homekit.driver.mock_calls, [call.start()]) self.assertTrue(homekit.started) diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 45631a76c98..2dcb48a4d4c 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -3,11 +3,13 @@ import unittest from homeassistant.core import callback from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_CURRENT_POSITION) -from homeassistant.components.homekit.type_covers import WindowCovering + ATTR_POSITION, ATTR_CURRENT_POSITION, SUPPORT_STOP) +from homeassistant.components.homekit.type_covers import ( + GarageDoorOpener, WindowCovering, WindowCoveringBasic) from homeassistant.const import ( - STATE_UNKNOWN, STATE_OPEN, - ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE) + STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, + ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SUPPORTED_FEATURES) from tests.common import get_test_home_assistant @@ -31,11 +33,69 @@ class TestHomekitSensors(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() + def test_garage_door_open_close(self): + """Test if accessory and HA are updated accordingly.""" + garage_door = 'cover.garage_door' + + acc = GarageDoorOpener(self.hass, 'Cover', garage_door, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 4) # GarageDoorOpener + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 1) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(garage_door, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNAVAILABLE) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(garage_door, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + # Set closed from HomeKit + acc.char_target_state.client_update_value(1) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 2) + self.assertEqual(acc.char_target_state.value, 1) + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.hass.states.set(garage_door, STATE_CLOSED) + self.hass.block_till_done() + + # Set open from HomeKit + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 0) + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + def test_window_set_cover_position(self): """Test if accessory and HA are updated accordingly.""" window_cover = 'cover.window' - acc = WindowCovering(self.hass, window_cover, 'Cover', aid=2) + acc = WindowCovering(self.hass, 'Cover', window_cover, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -43,7 +103,6 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_UNKNOWN, {ATTR_CURRENT_POSITION: None}) @@ -51,7 +110,6 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 0) self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 0) self.hass.states.set(window_cover, STATE_OPEN, {ATTR_CURRENT_POSITION: 50}) @@ -59,10 +117,9 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 50) - self.assertEqual(acc.char_position_state.value, 2) # Set from HomeKit - acc.char_target_position.set_value(25) + acc.char_target_position.client_update_value(25) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_cover_position') @@ -71,16 +128,122 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 25) - self.assertEqual(acc.char_position_state.value, 0) # Set from HomeKit - acc.char_target_position.set_value(75) + acc.char_target_position.client_update_value(75) self.hass.block_till_done() self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') + self.events[1].data[ATTR_SERVICE], 'set_cover_position') self.assertEqual( self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) self.assertEqual(acc.char_current_position.value, 50) self.assertEqual(acc.char_target_position.value, 75) - self.assertEqual(acc.char_position_state.value, 1) + + def test_window_open_close(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: 0}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 14) # WindowCovering + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_OPEN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + self.hass.states.set(window_cover, STATE_CLOSED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + def test_window_open_close_stop(self): + """Test if accessory and HA are updated accordingly.""" + window_cover = 'cover.window' + + self.hass.states.set(window_cover, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) + acc = WindowCoveringBasic(self.hass, 'Cover', window_cover, 2, + config=None) + acc.run() + + # Set from HomeKit + acc.char_target_position.client_update_value(25) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'close_cover') + + self.assertEqual(acc.char_current_position.value, 0) + self.assertEqual(acc.char_target_position.value, 0) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(90) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'open_cover') + + self.assertEqual(acc.char_current_position.value, 100) + self.assertEqual(acc.char_target_position.value, 100) + self.assertEqual(acc.char_position_state.value, 2) + + # Set from HomeKit + acc.char_target_position.client_update_value(55) + self.hass.block_till_done() + self.assertEqual( + self.events[2].data[ATTR_SERVICE], 'stop_cover') + + self.assertEqual(acc.char_current_position.value, 50) + self.assertEqual(acc.char_target_position.value, 50) + self.assertEqual(acc.char_position_state.value, 2) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 1cfb926c4ce..10bf469c08d 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -2,7 +2,6 @@ import unittest from homeassistant.core import callback -from homeassistant.components.homekit.type_lights import Light from homeassistant.components.light import ( DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) @@ -12,11 +11,26 @@ from homeassistant.const import ( SERVICE_TURN_OFF, STATE_ON, STATE_OFF, STATE_UNKNOWN) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce class TestHomekitLights(unittest.TestCase): """Test class for all accessory types regarding lights.""" + @classmethod + def setUpClass(cls): + """Setup Light class import and debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + _import = __import__('homeassistant.components.homekit.type_lights', + fromlist=['Light']) + cls.light_cls = _import.Light + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() @@ -36,9 +50,11 @@ class TestHomekitLights(unittest.TestCase): def test_light_basic(self): """Test light with char state.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.aid, 2) self.assertEqual(acc.category, 5) # Lightbulb self.assertEqual(acc.char_on.value, 0) @@ -57,7 +73,7 @@ class TestHomekitLights(unittest.TestCase): self.assertEqual(acc.char_on.value, 0) # Set from HomeKit - acc.char_on.set_value(1) + acc.char_on.client_update_value(1) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -65,7 +81,7 @@ class TestHomekitLights(unittest.TestCase): self.hass.states.set(entity_id, STATE_ON) self.hass.block_till_done() - acc.char_on.set_value(0) + acc.char_on.client_update_value(0) self.hass.block_till_done() self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_OFF) @@ -80,9 +96,11 @@ class TestHomekitLights(unittest.TestCase): def test_light_brightness(self): """Test light with brightness.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_brightness.value, 0) acc.run() @@ -94,8 +112,8 @@ class TestHomekitLights(unittest.TestCase): self.assertEqual(acc.char_brightness.value, 40) # Set from HomeKit - acc.char_brightness.set_value(20) - acc.char_on.set_value(1) + acc.char_brightness.client_update_value(20) + acc.char_on.client_update_value(1) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -103,8 +121,8 @@ class TestHomekitLights(unittest.TestCase): self.events[0].data[ATTR_SERVICE_DATA], { ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 20}) - acc.char_on.set_value(1) - acc.char_brightness.set_value(40) + acc.char_on.client_update_value(1) + acc.char_brightness.client_update_value(40) self.hass.block_till_done() self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -112,8 +130,8 @@ class TestHomekitLights(unittest.TestCase): self.events[1].data[ATTR_SERVICE_DATA], { ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 40}) - acc.char_on.set_value(1) - acc.char_brightness.set_value(0) + acc.char_on.client_update_value(1) + acc.char_brightness.client_update_value(0) self.hass.block_till_done() self.assertEqual(self.events[2].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[2].data[ATTR_SERVICE], SERVICE_TURN_OFF) @@ -121,10 +139,12 @@ class TestHomekitLights(unittest.TestCase): def test_light_color_temperature(self): """Test light with color temperature.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, ATTR_COLOR_TEMP: 190}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_color_temperature.value, 153) acc.run() @@ -132,7 +152,7 @@ class TestHomekitLights(unittest.TestCase): self.assertEqual(acc.char_color_temperature.value, 190) # Set from HomeKit - acc.char_color_temperature.set_value(250) + acc.char_color_temperature.client_update_value(250) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) @@ -143,10 +163,12 @@ class TestHomekitLights(unittest.TestCase): def test_light_rgb_color(self): """Test light with rgb_color.""" entity_id = 'light.demo' + self.hass.states.set(entity_id, STATE_ON, { ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, ATTR_HS_COLOR: (260, 90)}) - acc = Light(self.hass, entity_id, 'Light', aid=2) + self.hass.block_till_done() + acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) self.assertEqual(acc.char_hue.value, 0) self.assertEqual(acc.char_saturation.value, 75) @@ -156,8 +178,8 @@ class TestHomekitLights(unittest.TestCase): self.assertEqual(acc.char_saturation.value, 90) # Set from HomeKit - acc.char_hue.set_value(145) - acc.char_saturation.set_value(75) + acc.char_hue.client_update_value(145) + acc.char_saturation.client_update_value(75) self.hass.block_till_done() self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py new file mode 100644 index 00000000000..b2053116060 --- /dev/null +++ b/tests/components/homekit/test_type_locks.py @@ -0,0 +1,77 @@ +"""Test different accessory types: Locks.""" +import unittest + +from homeassistant.core import callback +from homeassistant.components.homekit.type_locks import Lock +from homeassistant.const import ( + STATE_UNKNOWN, STATE_UNLOCKED, STATE_LOCKED, + ATTR_SERVICE, EVENT_CALL_SERVICE) + +from tests.common import get_test_home_assistant + + +class TestHomekitSensors(unittest.TestCase): + """Test class for all accessory types regarding covers.""" + + 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_lock_unlock(self): + """Test if accessory and HA are updated accordingly.""" + kitchen_lock = 'lock.kitchen_door' + + acc = Lock(self.hass, 'Lock', kitchen_lock, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 6) # DoorLock + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(kitchen_lock, STATE_LOCKED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 1) + self.assertEqual(acc.char_target_state.value, 1) + + self.hass.states.set(kitchen_lock, STATE_UNLOCKED) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 0) + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.set(kitchen_lock, STATE_UNKNOWN) + self.hass.block_till_done() + + self.assertEqual(acc.char_current_state.value, 3) + self.assertEqual(acc.char_target_state.value, 0) + + # Set from HomeKit + acc.char_target_state.client_update_value(1) + self.hass.block_till_done() + self.assertEqual( + self.events[0].data[ATTR_SERVICE], 'lock') + self.assertEqual(acc.char_target_state.value, 1) + + acc.char_target_state.client_update_value(0) + self.hass.block_till_done() + self.assertEqual( + self.events[1].data[ATTR_SERVICE], 'unlock') + self.assertEqual(acc.char_target_state.value, 0) + + self.hass.states.remove(kitchen_lock) + self.hass.block_till_done() diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index c689a73bac2..ec538ce4b50 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -35,8 +35,8 @@ class TestHomekitSecuritySystems(unittest.TestCase): """Test if accessory and HA are updated accordingly.""" acp = 'alarm_control_panel.test' - acc = SecuritySystem(self.hass, acp, 'SecuritySystem', - alarm_code='1234', aid=2) + acc = SecuritySystem(self.hass, 'SecuritySystem', acp, + 2, config={ATTR_CODE: '1234'}) acc.run() self.assertEqual(acc.aid, 2) @@ -71,7 +71,7 @@ class TestHomekitSecuritySystems(unittest.TestCase): self.assertEqual(acc.char_current_state.value, 3) # Set from HomeKit - acc.char_target_state.set_value(0) + acc.char_target_state.client_update_value(0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') @@ -79,7 +79,7 @@ class TestHomekitSecuritySystems(unittest.TestCase): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 0) - acc.char_target_state.set_value(1) + acc.char_target_state.client_update_value(1) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') @@ -87,7 +87,7 @@ class TestHomekitSecuritySystems(unittest.TestCase): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 1) - acc.char_target_state.set_value(2) + acc.char_target_state.client_update_value(2) self.hass.block_till_done() self.assertEqual( self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') @@ -95,7 +95,7 @@ class TestHomekitSecuritySystems(unittest.TestCase): self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') self.assertEqual(acc.char_target_state.value, 2) - acc.char_target_state.set_value(3) + acc.char_target_state.client_update_value(3) self.hass.block_till_done() self.assertEqual( self.events[3].data[ATTR_SERVICE], 'alarm_disarm') @@ -107,12 +107,12 @@ class TestHomekitSecuritySystems(unittest.TestCase): """Test accessory if security_system doesn't require a alarm_code.""" acp = 'alarm_control_panel.test' - acc = SecuritySystem(self.hass, acp, 'SecuritySystem', - alarm_code=None, aid=2) + acc = SecuritySystem(self.hass, 'SecuritySystem', acp, + 2, config={ATTR_CODE: None}) acc.run() # Set from HomeKit - acc.char_target_state.set_value(0) + acc.char_target_state.client_update_value(0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c04c250613d..77bfc0c8901 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -3,9 +3,11 @@ import unittest from homeassistant.components.homekit.const import PROP_CELSIUS from homeassistant.components.homekit.type_sensors import ( - TemperatureSensor, HumiditySensor) + TemperatureSensor, HumiditySensor, AirQualitySensor, CarbonDioxideSensor, + LightSensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, STATE_UNKNOWN, STATE_ON, + STATE_OFF, STATE_HOME, STATE_NOT_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant @@ -25,7 +27,8 @@ class TestHomekitSensors(unittest.TestCase): """Test if accessory is updated after state change.""" entity_id = 'sensor.temperature' - acc = TemperatureSensor(self.hass, entity_id, 'Temperature', aid=2) + acc = TemperatureSensor(self.hass, 'Temperature', entity_id, + 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -38,6 +41,7 @@ class TestHomekitSensors(unittest.TestCase): self.hass.states.set(entity_id, STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) self.hass.block_till_done() + self.assertEqual(acc.char_temp.value, 0.0) self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -53,7 +57,7 @@ class TestHomekitSensors(unittest.TestCase): """Test if accessory is updated after state change.""" entity_id = 'sensor.humidity' - acc = HumiditySensor(self.hass, entity_id, 'Humidity', aid=2) + acc = HumiditySensor(self.hass, 'Humidity', entity_id, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -61,10 +65,145 @@ class TestHomekitSensors(unittest.TestCase): self.assertEqual(acc.char_humidity.value, 0) - self.hass.states.set(entity_id, STATE_UNKNOWN, - {ATTR_UNIT_OF_MEASUREMENT: "%"}) + self.hass.states.set(entity_id, STATE_UNKNOWN) self.hass.block_till_done() + self.assertEqual(acc.char_humidity.value, 0) - self.hass.states.set(entity_id, '20', {ATTR_UNIT_OF_MEASUREMENT: "%"}) + self.hass.states.set(entity_id, '20') self.hass.block_till_done() self.assertEqual(acc.char_humidity.value, 20) + + def test_air_quality(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.air_quality' + + acc = AirQualitySensor(self.hass, 'Air Quality', entity_id, + 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_density.value, 0) + self.assertEqual(acc.char_quality.value, 0) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 0) + self.assertEqual(acc.char_quality.value, 0) + + self.hass.states.set(entity_id, '34') + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 34) + self.assertEqual(acc.char_quality.value, 1) + + self.hass.states.set(entity_id, '200') + self.hass.block_till_done() + self.assertEqual(acc.char_density.value, 200) + self.assertEqual(acc.char_quality.value, 5) + + def test_co2(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.co2' + + acc = CarbonDioxideSensor(self.hass, 'CO2', entity_id, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_co2.value, 0) + self.assertEqual(acc.char_peak.value, 0) + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 0) + self.assertEqual(acc.char_peak.value, 0) + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, '1100') + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 1100) + self.assertEqual(acc.char_peak.value, 1100) + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, '800') + self.hass.block_till_done() + self.assertEqual(acc.char_co2.value, 800) + self.assertEqual(acc.char_peak.value, 1100) + self.assertEqual(acc.char_detected.value, 0) + + def test_light(self): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.light' + + acc = LightSensor(self.hass, 'Light', entity_id, 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_light.value, 0.0001) + + self.hass.states.set(entity_id, STATE_UNKNOWN) + self.hass.block_till_done() + self.assertEqual(acc.char_light.value, 0.0001) + + self.hass.states.set(entity_id, '300') + self.hass.block_till_done() + self.assertEqual(acc.char_light.value, 300) + + def test_binary(self): + """Test if accessory is updated after state change.""" + entity_id = 'binary_sensor.opening' + + self.hass.states.set(entity_id, STATE_UNKNOWN, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + + acc = BinarySensor(self.hass, 'Window Opening', entity_id, + 2, config=None) + acc.run() + + self.assertEqual(acc.aid, 2) + self.assertEqual(acc.category, 10) # Sensor + + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_ON, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.set(entity_id, STATE_HOME, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 1) + + self.hass.states.set(entity_id, STATE_NOT_HOME, + {ATTR_DEVICE_CLASS: "opening"}) + self.hass.block_till_done() + self.assertEqual(acc.char_detected.value, 0) + + self.hass.states.remove(entity_id) + self.hass.block_till_done() + + def test_binary_device_classes(self): + """Test if services and characteristics are assigned correctly.""" + entity_id = 'binary_sensor.demo' + + for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items(): + self.hass.states.set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: device_class}) + self.hass.block_till_done() + + acc = BinarySensor(self.hass, 'Binary Sensor', entity_id, + 2, config=None) + self.assertEqual(acc.get_service(service).display_name, service) + self.assertEqual(acc.char_detected.display_name, char) diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 21d7583152e..65b107e24cd 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -34,7 +34,7 @@ class TestHomekitSwitches(unittest.TestCase): entity_id = 'switch.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -51,14 +51,14 @@ class TestHomekitSwitches(unittest.TestCase): self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) self.assertEqual( self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - acc.char_on.set_value(False) + acc.char_on.client_update_value(False) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_DOMAIN], domain) @@ -70,13 +70,13 @@ class TestHomekitSwitches(unittest.TestCase): entity_id = 'remote.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) @@ -89,13 +89,13 @@ class TestHomekitSwitches(unittest.TestCase): entity_id = 'input_boolean.test' domain = split_entity_id(entity_id)[0] - acc = Switch(self.hass, entity_id, 'Switch', aid=2) + acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) acc.run() self.assertEqual(acc.char_on.value, False) # Set from HomeKit - acc.char_on.set_value(True) + acc.char_on.client_update_value(True) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_DOMAIN], domain) diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index e1511163f2f..adc3fb018f8 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -6,17 +6,33 @@ from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, STATE_COOL, STATE_HEAT, STATE_AUTO) -from homeassistant.components.homekit.type_thermostats import Thermostat from homeassistant.const import ( - ATTR_SERVICE, EVENT_CALL_SERVICE, ATTR_SERVICE_DATA, - ATTR_UNIT_OF_MEASUREMENT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, EVENT_CALL_SERVICE, + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from tests.common import get_test_home_assistant +from tests.components.homekit.test_accessories import patch_debounce class TestHomekitThermostats(unittest.TestCase): """Test class for all accessory types regarding thermostats.""" + @classmethod + def setUpClass(cls): + """Setup Thermostat class import and debounce patcher.""" + cls.patcher = patch_debounce() + cls.patcher.start() + _import = __import__( + 'homeassistant.components.homekit.type_thermostats', + fromlist=['Thermostat']) + cls.thermostat_cls = _import.Thermostat + + @classmethod + def tearDownClass(cls): + """Stop debounce patcher.""" + cls.patcher.stop() + def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() @@ -37,7 +53,10 @@ class TestHomekitThermostats(unittest.TestCase): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', False, aid=2) + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.assertEqual(acc.aid, 2) @@ -151,7 +170,7 @@ class TestHomekitThermostats(unittest.TestCase): self.assertEqual(acc.char_display_units.value, 0) # Set from HomeKit - acc.char_target_temp.set_value(19.0) + acc.char_target_temp.client_update_value(19.0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_temperature') @@ -159,7 +178,7 @@ class TestHomekitThermostats(unittest.TestCase): 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) + acc.char_target_heat_cool.client_update_value(1) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'set_operation_mode') @@ -172,7 +191,11 @@ class TestHomekitThermostats(unittest.TestCase): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', True) + # support_auto = True + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) @@ -221,7 +244,7 @@ class TestHomekitThermostats(unittest.TestCase): self.assertEqual(acc.char_display_units.value, 0) # Set from HomeKit - acc.char_heating_thresh_temp.set_value(20.0) + acc.char_heating_thresh_temp.client_update_value(20.0) self.hass.block_till_done() self.assertEqual( self.events[0].data[ATTR_SERVICE], 'set_temperature') @@ -229,7 +252,7 @@ class TestHomekitThermostats(unittest.TestCase): 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) + acc.char_cooling_thresh_temp.client_update_value(25.0) self.hass.block_till_done() self.assertEqual( self.events[1].data[ATTR_SERVICE], 'set_temperature') @@ -242,7 +265,11 @@ class TestHomekitThermostats(unittest.TestCase): """Test if accessory and HA are updated accordingly.""" climate = 'climate.test' - acc = Thermostat(self.hass, climate, 'Climate', True) + # support_auto = True + self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + self.hass.block_till_done() + acc = self.thermostat_cls(self.hass, 'Climate', climate, + 2, config=None) acc.run() self.hass.states.set(climate, STATE_AUTO, @@ -260,19 +287,19 @@ class TestHomekitThermostats(unittest.TestCase): self.assertEqual(acc.char_display_units.value, 1) # Set from HomeKit - acc.char_cooling_thresh_temp.set_value(23) + acc.char_cooling_thresh_temp.client_update_value(23) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 68) - acc.char_heating_thresh_temp.set_value(22) + acc.char_heating_thresh_temp.client_update_value(22) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 71.6) - acc.char_target_temp.set_value(24.0) + acc.char_target_temp.client_update_value(24.0) self.hass.block_till_done() service_data = self.events[-1].data[ATTR_SERVICE_DATA] self.assertEqual(service_data[ATTR_TEMPERATURE], 75.2) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index d6ef5856f85..4a9521384bd 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -2,13 +2,15 @@ import unittest import voluptuous as vol +import pytest from homeassistant.core import callback from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import HOMEKIT_NOTIFY_ID from homeassistant.components.homekit.util import ( show_setup_message, dismiss_setup_message, convert_to_float, - temperature_to_homekit, temperature_to_states, ATTR_CODE) + temperature_to_homekit, temperature_to_states, ATTR_CODE, + density_to_air_quality) from homeassistant.components.homekit.util import validate_entity_config \ as vec from homeassistant.components.persistent_notification import ( @@ -20,6 +22,52 @@ from homeassistant.const import ( from tests.common import get_test_home_assistant +def test_validate_entity_config(): + """Test validate entities.""" + configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, + {'demo.test': 'test'}, {'demo.test': [1, 2]}, + {'demo.test': None}] + + for conf in configs: + with pytest.raises(vol.Invalid): + vec(conf) + + assert vec({}) == {} + assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \ + {'alarm_control_panel.demo': {ATTR_CODE: '1234'}} + + +def test_convert_to_float(): + """Test convert_to_float method.""" + assert convert_to_float(12) == 12 + assert convert_to_float(12.4) == 12.4 + assert convert_to_float(STATE_UNKNOWN) is None + assert convert_to_float(None) is None + + +def test_temperature_to_homekit(): + """Test temperature conversion from HA to HomeKit.""" + assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5 + assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4 + + +def test_temperature_to_states(): + """Test temperature conversion from HomeKit to HA.""" + assert temperature_to_states(20, TEMP_CELSIUS) == 20.0 + assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4 + + +def test_density_to_air_quality(): + """Test map PM2.5 density to HomeKit AirQuality level.""" + assert density_to_air_quality(0) == 1 + assert density_to_air_quality(35) == 1 + assert density_to_air_quality(35.1) == 2 + assert density_to_air_quality(75) == 2 + assert density_to_air_quality(115) == 3 + assert density_to_air_quality(150) == 4 + assert density_to_air_quality(300) == 5 + + class TestUtil(unittest.TestCase): """Test all HomeKit util methods.""" @@ -39,26 +87,11 @@ class TestUtil(unittest.TestCase): """Stop down everything that was started.""" self.hass.stop() - def test_validate_entity_config(self): - """Test validate entities.""" - configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, - {'demo.test': 'test'}, {'demo.test': [1, 2]}, - {'demo.test': None}] - - for conf in configs: - with self.assertRaises(vol.Invalid): - vec(conf) - - self.assertEqual(vec({}), {}) - self.assertEqual( - vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}), - {'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) - def test_show_setup_msg(self): """Test show setup message as persistence notification.""" bridge = HomeBridge(self.hass) - show_setup_message(bridge, self.hass) + show_setup_message(self.hass, bridge) self.hass.block_till_done() data = self.events[0].data @@ -83,20 +116,3 @@ class TestUtil(unittest.TestCase): self.assertEqual( data[ATTR_SERVICE_DATA].get(ATTR_NOTIFICATION_ID, None), HOMEKIT_NOTIFY_ID) - - def test_convert_to_float(self): - """Test convert_to_float method.""" - self.assertEqual(convert_to_float(12), 12) - self.assertEqual(convert_to_float(12.4), 12.4) - self.assertIsNone(convert_to_float(STATE_UNKNOWN)) - self.assertIsNone(convert_to_float(None)) - - def test_temperature_to_homekit(self): - """Test temperature conversion from HA to HomeKit.""" - self.assertEqual(temperature_to_homekit(20.46, TEMP_CELSIUS), 20.5) - self.assertEqual(temperature_to_homekit(92.1, TEMP_FAHRENHEIT), 33.4) - - def test_temperature_to_states(self): - """Test temperature conversion from HomeKit to HA.""" - self.assertEqual(temperature_to_states(20, TEMP_CELSIUS), 20.0) - self.assertEqual(temperature_to_states(20.2, TEMP_FAHRENHEIT), 68.4) diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py index 0845aa2f077..c20cee0d0e8 100644 --- a/tests/components/hue/test_bridge.py +++ b/tests/components/hue/test_bridge.py @@ -18,10 +18,9 @@ async def test_bridge_setup(): assert await hue_bridge.async_setup() is True assert hue_bridge.api is api - assert len(hass.helpers.discovery.async_load_platform.mock_calls) == 1 - assert hass.helpers.discovery.async_load_platform.mock_calls[0][1][2] == { - 'host': '1.2.3.4' - } + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'light') async def test_bridge_setup_invalid_username(): @@ -55,3 +54,60 @@ async def test_bridge_setup_timeout(hass): assert len(hass.helpers.event.async_call_later.mock_calls) == 1 # Assert we are going to wait 2 seconds assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2 + + +async def test_reset_cancels_retry_setup(): + """Test resetting a bridge while we're waiting to retry setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', side_effect=errors.CannotConnect): + assert await hue_bridge.async_setup() is False + + mock_call_later = hass.helpers.event.async_call_later + + assert len(mock_call_later.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + assert len(mock_call_later.mock_calls) == 2 + assert len(mock_call_later.return_value.mock_calls) == 1 + + +async def test_reset_if_entry_had_wrong_auth(): + """Test calling reset when the entry contained wrong auth.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', + side_effect=errors.AuthenticationRequired): + assert await hue_bridge.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 1 + + assert await hue_bridge.async_reset() + + +async def test_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'username': 'mock-username'} + hue_bridge = bridge.HueBridge(hass, entry, False, False) + + with patch.object(bridge, 'get_bridge', return_value=mock_coro(Mock())): + assert await hue_bridge.async_setup() is True + + assert len(hass.services.async_register.mock_calls) == 1 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await hue_bridge.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1 + assert len(hass.services.async_remove.mock_calls) == 1 diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 47e74b70e83..ea656ba8fc6 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -167,3 +167,22 @@ async def test_config_passed_to_config_entry(hass): assert p_entry is entry assert p_allow_unreachable is True assert p_allow_groups is False + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hue.DOMAIN, data={ + 'host': '0.0.0.0', + }) + entry.add_to_hass(hass) + + with patch.object(hue, 'HueBridge') as mock_bridge: + mock_bridge.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hue.DOMAIN, {}) is True + + assert len(mock_bridge.return_value.mock_calls) == 1 + + mock_bridge.return_value.async_reset.return_value = mock_coro(True) + assert await hue.async_unload_entry(hass, entry) + assert len(mock_bridge.return_value.async_reset.mock_calls) == 1 + assert hass.data[hue.DOMAIN] == {} diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index 7b6c3a21a79..712cd17a7c7 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -9,6 +9,7 @@ from aiohue.lights import Lights from aiohue.groups import Groups import pytest +from homeassistant import config_entries from homeassistant.components import hue import homeassistant.components.light.hue as hue_light from homeassistant.util import color @@ -196,9 +197,11 @@ async def setup_bridge(hass, mock_bridge): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) hass.data[hue.DOMAIN] = {'mock-host': mock_bridge} - await hass.helpers.discovery.async_load_platform('light', 'hue', { + config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', { 'host': 'mock-host' - }) + }, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'light') + # To flush out the service call to update the group await hass.async_block_till_done() diff --git a/tests/components/media_player/test_blackbird.py b/tests/components/media_player/test_blackbird.py new file mode 100644 index 00000000000..86bfdfb52c4 --- /dev/null +++ b/tests/components/media_player/test_blackbird.py @@ -0,0 +1,328 @@ +"""The tests for the Monoprice Blackbird media player platform.""" +import unittest +from unittest import mock +import voluptuous as vol + +from collections import defaultdict +from homeassistant.components.media_player import ( + DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_SELECT_SOURCE) +from homeassistant.const import STATE_ON, STATE_OFF + +import tests.common +from homeassistant.components.media_player.blackbird import ( + DATA_BLACKBIRD, PLATFORM_SCHEMA, SERVICE_SETALLZONES, setup_platform) + + +class AttrDict(dict): + """Helper clas for mocking attributes.""" + + def __setattr__(self, name, value): + """Set attribute.""" + self[name] = value + + def __getattr__(self, item): + """Get attribute.""" + return self[item] + + +class MockBlackbird(object): + """Mock for pyblackbird object.""" + + def __init__(self): + """Init mock object.""" + self.zones = defaultdict(lambda: AttrDict(power=True, + av=1)) + + def zone_status(self, zone_id): + """Get zone status.""" + status = self.zones[zone_id] + status.zone = zone_id + return AttrDict(status) + + def set_zone_source(self, zone_id, source_idx): + """Set source for zone.""" + self.zones[zone_id].av = source_idx + + def set_zone_power(self, zone_id, power): + """Turn zone on/off.""" + self.zones[zone_id].power = power + + def set_all_zone_source(self, source_idx): + """Set source for all zones.""" + self.zones[3].av = source_idx + + +class TestBlackbirdSchema(unittest.TestCase): + """Test Blackbird schema.""" + + def test_valid_serial_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'type': 'serial', + 'port': '/dev/ttyUSB0', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + 6: {'name': 'a'}, + 7: {'name': 'a'}, + 8: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_valid_socket_schema(self): + """Test valid schema.""" + valid_schema = { + 'platform': 'blackbird', + 'type': 'socket', + 'port': '192.168.1.50', + 'zones': {1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + 5: {'name': 'a'}, + }, + 'sources': { + 1: {'name': 'a'}, + 2: {'name': 'a'}, + 3: {'name': 'a'}, + 4: {'name': 'a'}, + } + } + PLATFORM_SCHEMA(valid_schema) + + def test_invalid_schemas(self): + """Test invalid schemas.""" + schemas = ( + {}, # Empty + None, # None + # Missing type + { + 'platform': 'blackbird', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid zone number + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {11: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + # Invalid source number + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {9: {'name': 'b'}}, + }, + # Zone missing name + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {}}, + 'sources': {1: {'name': 'b'}}, + }, + # Source missing name + { + 'platform': 'blackbird', + 'type': 'serial', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {}}, + }, + # Invalid type + { + 'platform': 'blackbird', + 'type': 'aaa', + 'port': 'aaa', + 'name': 'Name', + 'zones': {1: {'name': 'a'}}, + 'sources': {1: {'name': 'b'}}, + }, + ) + for value in schemas: + with self.assertRaises(vol.MultipleInvalid): + PLATFORM_SCHEMA(value) + + +class TestBlackbirdMediaPlayer(unittest.TestCase): + """Test the media_player module.""" + + def setUp(self): + """Set up the test case.""" + self.blackbird = MockBlackbird() + self.hass = tests.common.get_test_home_assistant() + self.hass.start() + # Note, source dictionary is unsorted! + with mock.patch('pyblackbird.get_blackbird', + new=lambda *a: self.blackbird): + setup_platform(self.hass, { + 'platform': 'blackbird', + 'type': 'serial', + 'port': '/dev/ttyUSB0', + 'zones': {3: {'name': 'Zone name'}}, + 'sources': {1: {'name': 'one'}, + 3: {'name': 'three'}, + 2: {'name': 'two'}}, + }, lambda *args, **kwargs: None, {}) + self.hass.block_till_done() + self.media_player = self.hass.data[DATA_BLACKBIRD][0] + self.media_player.hass = self.hass + self.media_player.entity_id = 'media_player.zone_3' + + def tearDown(self): + """Tear down the test case.""" + self.hass.stop() + + def test_setup_platform(self, *args): + """Test setting up platform.""" + # One service must be registered + self.assertTrue(self.hass.services.has_service(DOMAIN, + SERVICE_SETALLZONES)) + self.assertEqual(len(self.hass.data[DATA_BLACKBIRD]), 1) + self.assertEqual(self.hass.data[DATA_BLACKBIRD][0].name, 'Zone name') + + def test_setallzones_service_call_with_entity_id(self): + """Test set all zone source service call with entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'entity_id': 'media_player.zone_3', + 'source': 'three'}, + blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_setallzones_service_call_without_entity_id(self): + """Test set all zone source service call without entity id.""" + self.media_player.update() + self.assertEqual('Zone name', self.media_player.name) + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + # Call set all zones service + self.hass.services.call(DOMAIN, SERVICE_SETALLZONES, + {'source': 'three'}, blocking=True) + + # Check that source was changed + self.assertEqual(3, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('three', self.media_player.source) + + def test_update(self): + """Test updating values from blackbird.""" + self.assertIsNone(self.media_player.state) + self.assertIsNone(self.media_player.source) + + self.media_player.update() + + self.assertEqual(STATE_ON, self.media_player.state) + self.assertEqual('one', self.media_player.source) + + def test_name(self): + """Test name property.""" + self.assertEqual('Zone name', self.media_player.name) + + def test_state(self): + """Test state property.""" + self.assertIsNone(self.media_player.state) + + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(SUPPORT_TURN_ON | SUPPORT_TURN_OFF | + SUPPORT_SELECT_SOURCE, + self.media_player.supported_features) + + def test_source(self): + """Test source property.""" + self.assertIsNone(self.media_player.source) + self.media_player.update() + self.assertEqual('one', self.media_player.source) + + def test_media_title(self): + """Test media title property.""" + self.assertIsNone(self.media_player.media_title) + self.media_player.update() + self.assertEqual('one', self.media_player.media_title) + + def test_source_list(self): + """Test source list property.""" + # Note, the list is sorted! + self.assertEqual(['one', 'two', 'three'], + self.media_player.source_list) + + def test_select_source(self): + """Test source selection methods.""" + self.media_player.update() + + self.assertEqual('one', self.media_player.source) + + self.media_player.select_source('two') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + # Trying to set unknown source. + self.media_player.select_source('no name') + self.assertEqual(2, self.blackbird.zones[3].av) + self.media_player.update() + self.assertEqual('two', self.media_player.source) + + def test_turn_on(self): + """Testing turning on the zone.""" + self.blackbird.zones[3].power = False + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) + + self.media_player.turn_on() + self.assertTrue(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + def test_turn_off(self): + """Testing turning off the zone.""" + self.blackbird.zones[3].power = True + self.media_player.update() + self.assertEqual(STATE_ON, self.media_player.state) + + self.media_player.turn_off() + self.assertFalse(self.blackbird.zones[3].power) + self.media_player.update() + self.assertEqual(STATE_OFF, self.media_player.state) diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index ee69ec1c85d..0c0f3906dc2 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -1,6 +1,7 @@ """The tests for the Cast Media player platform.""" # pylint: disable=protected-access import asyncio +import datetime as dt from typing import Optional from unittest.mock import patch, MagicMock, Mock from uuid import UUID @@ -14,7 +15,8 @@ from homeassistant.components.media_player.cast import ChromecastInfo from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers.dispatcher import async_dispatcher_connect, \ async_dispatcher_send -from homeassistant.components.media_player import cast +from homeassistant.components.media_player import cast, \ + ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT from homeassistant.setup import async_setup_component @@ -286,6 +288,8 @@ async def test_entity_media_states(hass: HomeAssistantType): assert entity.unique_id == full_info.uuid media_status = MagicMock(images=None) + media_status.current_time = 0 + media_status.playback_rate = 1 media_status.player_is_playing = True entity.new_media_status(media_status) await hass.async_block_till_done() @@ -320,6 +324,85 @@ async def test_entity_media_states(hass: HomeAssistantType): assert state.state == 'unknown' +async def test_entity_media_position(hass: HomeAssistantType): + """Test various entity media states.""" + info = get_fake_chromecast_info() + full_info = attr.evolve(info, model_name='google home', + friendly_name='Speaker', uuid=FakeUUID) + + with patch('pychromecast.dial.get_device_status', + return_value=full_info): + chromecast, entity = await async_setup_media_player_cast(hass, info) + + media_status = MagicMock(images=None) + media_status.current_time = 10 + media_status.playback_rate = 1 + media_status.player_is_playing = True + media_status.player_is_paused = False + media_status.player_is_idle = False + now = dt.datetime.now(dt.timezone.utc) + with patch('homeassistant.util.dt.utcnow', return_value=now): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 10 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now + + media_status.current_time = 15 + now_plus_5 = now + dt.timedelta(seconds=5) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 10 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now + + media_status.current_time = 20 + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_5): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 20 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_5 + + media_status.current_time = 25 + now_plus_10 = now + dt.timedelta(seconds=10) + media_status.player_is_playing = False + media_status.player_is_paused = True + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_10): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 25 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 + + now_plus_15 = now + dt.timedelta(seconds=15) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_15): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 25 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_10 + + media_status.current_time = 30 + now_plus_20 = now + dt.timedelta(seconds=20) + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert state.attributes[ATTR_MEDIA_POSITION] == 30 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == now_plus_20 + + media_status.player_is_paused = False + media_status.player_is_idle = True + with patch('homeassistant.util.dt.utcnow', return_value=now_plus_20): + entity.new_media_status(media_status) + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') + assert ATTR_MEDIA_POSITION not in state.attributes + assert ATTR_MEDIA_POSITION_UPDATED_AT not in state.attributes + + async def test_switched_host(hass: HomeAssistantType): """Test cast device listens for changed hosts and disconnects old cast.""" info = get_fake_chromecast_info() diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 0d4082731ab..43432f3304c 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -7,7 +7,9 @@ from homeassistant.components.sensor.filter import ( LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component +import homeassistant.core as ha +from tests.common import (get_test_home_assistant, assert_setup_component, + init_recorder_component) class TestFilterSensor(unittest.TestCase): @@ -16,12 +18,24 @@ class TestFilterSensor(unittest.TestCase): 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] + raw_values = [20, 19, 18, 21, 22, 0] + self.values = [] + + timestamp = dt_util.utcnow() + for val in raw_values: + self.values.append(ha.State('sensor.test_monitored', + val, last_updated=timestamp)) + timestamp += timedelta(minutes=1) def teardown_method(self, method): """Stop everything that was started.""" self.hass.stop() + def init_recorder(self): + """Initialize the recorder.""" + init_recorder_component(self.hass) + self.hass.start() + def test_setup_fail(self): """Test if filter doesn't exist.""" config = { @@ -36,41 +50,73 @@ class TestFilterSensor(unittest.TestCase): def test_chain(self): """Test if filter chaining works.""" + self.init_recorder() config = { + 'history': { + }, 'sensor': { 'platform': 'filter', 'name': 'test', 'entity_id': 'sensor.test_monitored', + 'history_period': '00:05', 'filters': [{ 'filter': 'outlier', + 'window_size': 10, '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) + t_0 = dt_util.utcnow() - timedelta(minutes=1) + t_1 = dt_util.utcnow() - timedelta(minutes=2) + t_2 = dt_util.utcnow() - timedelta(minutes=3) - for value in self.values: - self.hass.states.set(config['sensor']['entity_id'], value) - self.hass.block_till_done() + fake_states = { + 'sensor.test_monitored': [ + ha.State('sensor.test_monitored', 18.0, last_changed=t_0), + ha.State('sensor.test_monitored', 19.0, last_changed=t_1), + ha.State('sensor.test_monitored', 18.2, last_changed=t_2), + ] + } - state = self.hass.states.get('sensor.test') - self.assertEqual('20.25', state.state) + with patch('homeassistant.components.history.' + 'state_changes_during_period', return_value=fake_states): + with patch('homeassistant.components.history.' + 'get_last_state_changes', return_value=fake_states): + with assert_setup_component(1, 'sensor'): + assert setup_component(self.hass, 'sensor', config) + + for value in self.values: + self.hass.states.set( + config['sensor']['entity_id'], value.state) + self.hass.block_till_done() + + state = self.hass.states.get('sensor.test') + self.assertEqual('17.05', state.state) def test_outlier(self): """Test if outlier filter works.""" - filt = OutlierFilter(window_size=10, + filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(22, filtered) + self.assertEqual(22, filtered.state) + + def test_initial_outlier(self): + """Test issue #13363.""" + filt = OutlierFilter(window_size=3, + precision=2, + entity=None, + radius=4.0) + out = ha.State('sensor.test_monitored', 4000) + for state in [out]+self.values: + filtered = filt.filter_state(state) + self.assertEqual(22, filtered.state) def test_lowpass(self): """Test if lowpass filter works.""" @@ -80,7 +126,7 @@ class TestFilterSensor(unittest.TestCase): time_constant=10) for state in self.values: filtered = filt.filter_state(state) - self.assertEqual(18.05, filtered) + self.assertEqual(18.05, filtered.state) def test_throttle(self): """Test if lowpass filter works.""" @@ -92,7 +138,7 @@ class TestFilterSensor(unittest.TestCase): new_state = filt.filter_state(state) if not filt.skip_processing: filtered.append(new_state) - self.assertEqual([20, 21], filtered) + self.assertEqual([20, 21], [f.state for f in filtered]) def test_time_sma(self): """Test if time_sma filter works.""" @@ -100,9 +146,6 @@ class TestFilterSensor(unittest.TestCase): precision=2, entity=None, type='last') - past = dt_util.utcnow() - timedelta(minutes=5) for state in self.values: - with patch('homeassistant.util.dt.utcnow', return_value=past): - filtered = filt.filter_state(state) - past += timedelta(minutes=1) - self.assertEqual(21.5, filtered) + filtered = filt.filter_state(state) + self.assertEqual(21.5, filtered.state) diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index b23d89e3057..88e74e11008 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -329,3 +329,24 @@ class TestSensorMQTT(unittest.TestCase): self.assertEqual('100', state.attributes.get('val')) self.assertEqual('100', state.state) + + def test_unique_id(self): + """Test unique id option only creates one sensor per unique_id.""" + assert setup_component(self.hass, sensor.DOMAIN, { + sensor.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + + assert len(self.hass.states.all()) == 1 diff --git a/tests/components/sensor/test_qwikswitch.py b/tests/components/sensor/test_qwikswitch.py new file mode 100644 index 00000000000..d9dfe072fc0 --- /dev/null +++ b/tests/components/sensor/test_qwikswitch.py @@ -0,0 +1,87 @@ +"""Test qwikswitch sensors.""" +import logging + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH +from homeassistant.bootstrap import async_setup_component +from tests.test_util.aiohttp import mock_aiohttp_client + + +_LOGGER = logging.getLogger(__name__) + + +class AiohttpClientMockResponseList(list): + """List that fires an event on empty pop, for aiohttp Mocker.""" + + def decode(self, _): + """Return next item from list.""" + try: + res = list.pop(self) + _LOGGER.debug("MockResponseList popped %s: %s", res, self) + return res + except IndexError: + _LOGGER.debug("MockResponseList empty") + return "" + + async def wait_till_empty(self, hass): + """Wait until empty.""" + while self: + await hass.async_block_till_done() + await hass.async_block_till_done() + + +LISTEN = AiohttpClientMockResponseList() + + +@pytest.fixture +def aioclient_mock(): + """HTTP client listen and devices.""" + devices = """[ + {"id":"@000001","name":"Switch 1","type":"rel","val":"OFF", + "time":"1522777506","rssi":"51%"}, + {"id":"@000002","name":"Light 2","type":"rel","val":"ON", + "time":"1522777507","rssi":"45%"}, + {"id":"@000003","name":"Dim 3","type":"dim","val":"280c00", + "time":"1522777544","rssi":"62%"}]""" + + with mock_aiohttp_client() as mock_session: + mock_session.get("http://127.0.0.1:2020/&listen", content=LISTEN) + mock_session.get("http://127.0.0.1:2020/&device", text=devices) + yield mock_session + + +async def test_sensor_device(hass, aioclient_mock): + """Test a sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 's1', + 'id': '@a00001', + 'channel': 1, + 'type': 'imod', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.s1') + assert state_obj + assert state_obj.state == 'None' + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append( # Close + """{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}""") + await hass.async_block_till_done() + state_obj = hass.states.get('sensor.s1') + assert state_obj.state == 'True' + + # Causes a 30second delay: can be uncommented when upstream library + # allows cancellation of asyncio.sleep(30) on failed packet ("") + # LISTEN.append( # Open + # """{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}""") + # await LISTEN.wait_till_empty(hass) + # state_obj = hass.states.get('sensor.s1') + # assert state_obj.state == 'False' diff --git a/tests/components/sensor/test_sigfox.py b/tests/components/sensor/test_sigfox.py new file mode 100644 index 00000000000..dcdeef56b98 --- /dev/null +++ b/tests/components/sensor/test_sigfox.py @@ -0,0 +1,68 @@ +"""Tests for the sigfox sensor.""" +import re +import requests_mock +import unittest + +from homeassistant.components.sensor.sigfox import ( + API_URL, CONF_API_LOGIN, CONF_API_PASSWORD) +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant + +TEST_API_LOGIN = 'foo' +TEST_API_PASSWORD = 'ebcd1234' + +VALID_CONFIG = { + 'sensor': { + 'platform': 'sigfox', + CONF_API_LOGIN: TEST_API_LOGIN, + CONF_API_PASSWORD: TEST_API_PASSWORD}} + +VALID_MESSAGE = """ +{"data":[{ +"time":1521879720, +"data":"7061796c6f6164", +"rinfos":[{"lat":"0.0","lng":"0.0"}], +"snr":"50.0"}]} +""" + + +class TestSigfoxSensor(unittest.TestCase): + """Test the sigfox platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_invalid_credentials(self): + """Test for a invalid credentials.""" + with requests_mock.Mocker() as mock_req: + url = re.compile(API_URL + 'devicetypes') + mock_req.get(url, text='{}', status_code=401) + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + assert len(self.hass.states.entity_ids()) == 0 + + def test_valid_credentials(self): + """Test for a valid credentials.""" + with requests_mock.Mocker() as mock_req: + url1 = re.compile(API_URL + 'devicetypes') + mock_req.get(url1, text='{"data":[{"id":"fake_type"}]}', + status_code=200) + + url2 = re.compile(API_URL + 'devicetypes/fake_type/devices') + mock_req.get(url2, text='{"data":[{"id":"fake_id"}]}') + + url3 = re.compile(API_URL + 'devices/fake_id/messages*') + mock_req.get(url3, text=VALID_MESSAGE) + + self.assertTrue( + setup_component(self.hass, 'sensor', VALID_CONFIG)) + + assert len(self.hass.states.entity_ids()) == 1 + state = self.hass.states.get('sensor.sigfox_fake_id') + assert state.state == 'payload' + assert state.attributes.get('snr') == '50.0' diff --git a/tests/components/test_deconz.py b/tests/components/test_deconz.py deleted file mode 100644 index 2c7c656d560..00000000000 --- a/tests/components/test_deconz.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for deCONZ config flow.""" -import pytest - -import voluptuous as vol - -import homeassistant.components.deconz as deconz -import pydeconz - - -async def test_flow_works(hass, aioclient_mock): - """Test config flow.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': '80'} - ]) - aioclient_mock.post('http://1.2.3.4:80/api', json=[ - {"success": {"username": "1234567890ABCDEF"}} - ]) - - flow = deconz.DeconzFlowHandler() - flow.hass = hass - await flow.async_step_init() - result = await flow.async_step_link(user_input={}) - - assert result['type'] == 'create_entry' - assert result['title'] == 'deCONZ' - assert result['data'] == { - 'bridgeid': 'id', - 'host': '1.2.3.4', - 'port': '80', - 'api_key': '1234567890ABCDEF' - } - - -async def test_flow_already_registered_bridge(hass, aioclient_mock): - """Test config flow don't allow more than one bridge to be registered.""" - flow = deconz.DeconzFlowHandler() - flow.hass = hass - flow.hass.data[deconz.DOMAIN] = True - - result = await flow.async_step_init() - assert result['type'] == 'abort' - - -async def test_flow_no_discovered_bridges(hass, aioclient_mock): - """Test config flow discovers no bridges.""" - aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) - flow = deconz.DeconzFlowHandler() - 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(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': '80'} - ]) - flow = deconz.DeconzFlowHandler() - 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(pydeconz.utils.URL_DISCOVER, json=[ - {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': '80'}, - {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': '80'} - ]) - flow = deconz.DeconzFlowHandler() - 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_no_api_key(hass, aioclient_mock): - """Test config flow discovers no bridges.""" - aioclient_mock.post('http://1.2.3.4:80/api', json=[]) - flow = deconz.DeconzFlowHandler() - flow.hass = hass - flow.deconz_config = {'host': '1.2.3.4', 'port': 80} - - result = await flow.async_step_link(user_input={}) - assert result['type'] == 'form' - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'no_key'} diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index b4c80bf3210..dd22c87cb18 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock import pytest -from homeassistant import config_entries +from homeassistant import data_entry_flow from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -25,7 +25,8 @@ UNKNOWN_SERVICE = 'this_service_will_never_be_supported' BASE_CONFIG = { discovery.DOMAIN: { - 'ignore': [] + 'ignore': [], + 'enable': [] } } @@ -168,11 +169,11 @@ async def test_discover_config_flow(hass): with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, { 'mock-service': 'mock-component'}), patch( - 'homeassistant.config_entries.FlowManager.async_init') as m_init: + 'homeassistant.data_entry_flow.FlowManager.async_init') as m_init: await mock_discovery(hass, discover) assert len(m_init.mock_calls) == 1 args, kwargs = m_init.mock_calls[0][1:] assert args == ('mock-component',) - assert kwargs['source'] == config_entries.SOURCE_DISCOVERY + assert kwargs['source'] == data_entry_flow.SOURCE_DISCOVERY assert kwargs['data'] == discovery_info diff --git a/tests/components/test_google.py b/tests/components/test_google.py index fd45cfc59a9..0ee066fcfee 100644 --- a/tests/components/test_google.py +++ b/tests/components/test_google.py @@ -58,6 +58,7 @@ class TestGoogle(unittest.TestCase): 'device_id': 'we_are_we_are_a_test_calendar', 'name': 'We are, we are, a... Test Calendar', 'track': True, + 'ignore_availability': True, }] }) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index bea2af396cb..5d909492380 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -131,6 +131,39 @@ class TestComponentHistory(unittest.TestCase): self.assertEqual(states, hist[entity_id]) + def test_get_last_state_changes(self): + """Test number of state changes.""" + self.init_recorder() + entity_id = 'sensor.test' + + def set_state(state): + """Set the state.""" + self.hass.states.set(entity_id, state) + self.wait_recording_done() + return self.hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=2) + point = start + timedelta(minutes=1) + point2 = point + timedelta(minutes=1) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=start): + set_state('1') + + states = [] + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point): + states.append(set_state('2')) + + with patch('homeassistant.components.recorder.dt_util.utcnow', + return_value=point2): + states.append(set_state('3')) + + hist = history.get_last_state_changes( + self.hass, 2, entity_id) + + self.assertEqual(states, hist[entity_id]) + def test_get_significant_states(self): """Test that only significant states are returned. diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index f37beef7960..2342e897708 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -1,20 +1,92 @@ """Test the Snips component.""" -import asyncio import json import logging -from homeassistant.core import callback from homeassistant.bootstrap import async_setup_component +from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA +import homeassistant.components.snips as snips from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) -from homeassistant.components.snips import (SERVICE_SCHEMA_SAY, - SERVICE_SCHEMA_SAY_ACTION) -@asyncio.coroutine -def test_snips_intent(hass, mqtt_mock): +async def test_snips_config(hass, mqtt_mock): + """Test Snips Config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True, + "probability_threshold": .5, + "site_ids": ["default", "remote"] + }, + }) + assert result + + +async def test_snips_bad_config(hass, mqtt_mock): + """Test Snips bad config.""" + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": "on", + "probability": "none", + "site_ids": "default" + }, + }) + assert not result + + +async def test_snips_config_feedback_on(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": True + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + assert calls[1].data['qos'] == 1 + assert calls[1].data['retain'] + + +async def test_snips_config_feedback_off(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { + "snips": { + "feedback_sounds": False + }, + }) + assert result + await hass.async_block_till_done() + + assert len(calls) == 2 + topic = calls[0].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOn' + topic = calls[1].data['topic'] + assert topic == 'hermes/feedback/sound/toggleOff' + assert calls[1].data['qos'] == 0 + assert not calls[1].data['retain'] + + +async def test_snips_config_no_feedback(hass, mqtt_mock): + """Test Snips Config.""" + calls = async_mock_service(hass, 'snips', 'say') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_snips_intent(hass, mqtt_mock): """Test intent via Snips.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -41,7 +113,7 @@ def test_snips_intent(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -50,10 +122,9 @@ def test_snips_intent(hass, mqtt_mock): assert intent.text_input == 'turn the lights green' -@asyncio.coroutine -def test_snips_intent_with_duration(hass, mqtt_mock): +async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -61,7 +132,8 @@ def test_snips_intent_with_duration(hass, mqtt_mock): { "input": "set a timer of five minutes", "intent": { - "intentName": "SetTimer" + "intentName": "SetTimer", + "probability": 1 }, "slots": [ { @@ -92,7 +164,7 @@ def test_snips_intent_with_duration(hass, mqtt_mock): async_fire_mqtt_message(hass, 'hermes/intent/SetTimer', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] assert intent.platform == 'snips' @@ -100,22 +172,14 @@ def test_snips_intent_with_duration(hass, mqtt_mock): assert intent.slots == {'timer_duration': {'value': 300}} -@asyncio.coroutine -def test_intent_speech_response(hass, mqtt_mock): +async def test_intent_speech_response(hass, mqtt_mock): """Test intent speech response via Snips.""" - event = 'call_service' - events = [] - - @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) - - result = yield from async_setup_component(hass, "snips", { + calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA) + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result - result = yield from async_setup_component(hass, "intent_script", { + result = await async_setup_component(hass, "intent_script", { "intent_script": { "spokenIntent": { "speech": { @@ -131,31 +195,28 @@ def test_intent_speech_response(hass, mqtt_mock): "input": "speak to me", "sessionId": "abcdef0123456789", "intent": { - "intentName": "spokenIntent" + "intentName": "spokenIntent", + "probability": 1 }, "slots": [] } """ - hass.bus.async_listen(event, record_event) async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() - assert len(events) == 1 - assert events[0].data['domain'] == 'mqtt' - assert events[0].data['service'] == 'publish' - payload = json.loads(events[0].data['service_data']['payload']) - topic = events[0].data['service_data']['topic'] + assert len(calls) == 1 + payload = json.loads(calls[0].data['payload']) + topic = calls[0].data['topic'] assert payload['sessionId'] == 'abcdef0123456789' assert payload['text'] == 'I am speaking to you' assert topic == 'hermes/dialogueManager/endSession' -@asyncio.coroutine -def test_unknown_intent(hass, mqtt_mock, caplog): +async def test_unknown_intent(hass, mqtt_mock, caplog): """Test unknown intent.""" caplog.set_level(logging.WARNING) - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -164,21 +225,21 @@ def test_unknown_intent(hass, mqtt_mock, caplog): "input": "I don't know what I am supposed to do", "sessionId": "abcdef1234567890", "intent": { - "intentName": "unknownIntent" + "intentName": "unknownIntent", + "probability": 1 }, "slots": [] } """ async_fire_mqtt_message(hass, 'hermes/intent/unknownIntent', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert 'Received unknown intent unknownIntent' in caplog.text -@asyncio.coroutine -def test_snips_intent_user(hass, mqtt_mock): +async def test_snips_intent_user(hass, mqtt_mock): """Test intentName format user_XXX__intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -186,7 +247,8 @@ def test_snips_intent_user(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "user_ABCDEF123__Lights" + "intentName": "user_ABCDEF123__Lights", + "probability": 1 }, "slots": [] } @@ -194,7 +256,7 @@ def test_snips_intent_user(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -202,10 +264,9 @@ def test_snips_intent_user(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_intent_username(hass, mqtt_mock): +async def test_snips_intent_username(hass, mqtt_mock): """Test intentName format username:intentName.""" - result = yield from async_setup_component(hass, "snips", { + result = await async_setup_component(hass, "snips", { "snips": {}, }) assert result @@ -213,7 +274,8 @@ def test_snips_intent_username(hass, mqtt_mock): { "input": "what to do", "intent": { - "intentName": "username:Lights" + "intentName": "username:Lights", + "probability": 1 }, "slots": [] } @@ -221,7 +283,7 @@ def test_snips_intent_username(hass, mqtt_mock): intents = async_mock_intent(hass, 'Lights') async_fire_mqtt_message(hass, 'hermes/intent/username:Lights', payload) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(intents) == 1 intent = intents[0] @@ -229,15 +291,41 @@ def test_snips_intent_username(hass, mqtt_mock): assert intent.intent_type == 'Lights' -@asyncio.coroutine -def test_snips_say(hass, caplog): +async def test_snips_low_probability(hass, mqtt_mock, caplog): + """Test intent via Snips.""" + caplog.set_level(logging.WARNING) + result = await async_setup_component(hass, "snips", { + "snips": { + "probability_threshold": 0.5 + }, + }) + assert result + payload = """ + { + "input": "I am not sure what to say", + "intent": { + "intentName": "LightsMaybe", + "probability": 0.49 + }, + "slots": [] + } + """ + + async_mock_intent(hass, 'LightsMaybe') + async_fire_mqtt_message(hass, 'hermes/intent/LightsMaybe', + payload) + await hass.async_block_till_done() + assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text + + +async def test_snips_say(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -245,15 +333,14 @@ def test_snips_say(hass, caplog): assert calls[0].data['text'] == 'Hello' -@asyncio.coroutine -def test_snips_say_action(hass, caplog): +async def test_snips_say_action(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'intent_filter': ['myIntent']} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].domain == 'snips' @@ -262,31 +349,71 @@ def test_snips_say_action(hass, caplog): assert calls[0].data['intent_filter'] == ['myIntent'] -@asyncio.coroutine -def test_snips_say_invalid_config(hass, caplog): +async def test_snips_say_invalid_config(hass, caplog): """Test snips say with invalid config.""" calls = async_mock_service(hass, 'snips', 'say', - SERVICE_SCHEMA_SAY) + snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - yield from hass.services.async_call('snips', 'say', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text -@asyncio.coroutine -def test_snips_say_action_invalid_config(hass, caplog): +async def test_snips_say_action_invalid(hass, caplog): """Test snips say_action with invalid config.""" calls = async_mock_service(hass, 'snips', 'say_action', - SERVICE_SCHEMA_SAY_ACTION) + snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - yield from hass.services.async_call('snips', 'say_action', data) - yield from hass.async_block_till_done() + await hass.services.async_call('snips', 'say_action', data) + await hass.async_block_till_done() assert len(calls) == 0 assert 'ERROR' in caplog.text assert 'Invalid service data' in caplog.text + + +async def test_snips_feedback_on(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_on' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_off(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_off', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote'} + await hass.services.async_call('snips', 'feedback_off', data) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'snips' + assert calls[0].service == 'feedback_off' + assert calls[0].data['site_id'] == 'remote' + + +async def test_snips_feedback_config(hass, caplog): + """Test snips say with invalid config.""" + calls = async_mock_service(hass, 'snips', 'feedback_on', + snips.SERVICE_SCHEMA_FEEDBACK) + + data = {'site_id': 'remote', 'test': 'test'} + await hass.services.async_call('snips', 'feedback_on', data) + await hass.async_block_till_done() + + assert len(calls) == 0 diff --git a/tests/components/test_upnp.py b/tests/components/test_upnp.py index e2096d28e58..4956b8a6278 100644 --- a/tests/components/test_upnp.py +++ b/tests/components/test_upnp.py @@ -1,5 +1,4 @@ """Test the UPNP component.""" -import asyncio from collections import OrderedDict from unittest.mock import patch, MagicMock @@ -7,15 +6,64 @@ import pytest from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.setup import async_setup_component +from homeassistant.components.upnp import IP_SERVICE, DATA_UPNP + + +class MockService(MagicMock): + """Mock upnp IP service.""" + + async def add_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_add_port_mapping(*args, **kwargs) + + async def delete_port_mapping(self, *args, **kwargs): + """Original function.""" + self.mock_delete_port_mapping(*args, **kwargs) + + +class MockDevice(MagicMock): + """Mock upnp device.""" + + def find_first_service(self, *args, **kwargs): + """Original function.""" + self._service = MockService() + return self._service + + def peep_first_service(self): + """Access Mock first service.""" + return self._service + + +class MockResp(MagicMock): + """Mock upnp msearch response.""" + + async def get_device(self, *args, **kwargs): + """Original function.""" + device = MockDevice() + service = {'serviceType': IP_SERVICE} + device.services = [service] + return device @pytest.fixture -def mock_miniupnpc(): - """Mock miniupnpc.""" - mock = MagicMock() +def mock_msearch_first(*args, **kwargs): + """Wrapper to async mock function.""" + async def async_mock_msearch_first(*args, **kwargs): + """Mock msearch_first.""" + return MockResp(*args, **kwargs) - with patch.dict('sys.modules', {'miniupnpc': mock}): - yield mock.UPnP() + with patch('pyupnp_async.msearch_first', new=async_mock_msearch_first): + yield + + +@pytest.fixture +def mock_async_exception(*args, **kwargs): + """Wrapper to async mock function with exception.""" + async def async_mock_exception(*args, **kwargs): + return Exception + + with patch('pyupnp_async.msearch_first', new=async_mock_exception): + yield @pytest.fixture @@ -26,75 +74,66 @@ def mock_local_ip(): yield -@pytest.fixture(autouse=True) -def mock_discovery(): - """Mock discovery of upnp sensor.""" - with patch('homeassistant.components.upnp.discovery'): - yield - - -@asyncio.coroutine -def test_setup_fail_if_no_ip(hass): +async def test_setup_fail_if_no_ip(hass): """Test setup fails if we can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_fail_if_cannot_select_igd(hass, mock_local_ip, mock_miniupnpc): +async def test_setup_fail_if_cannot_select_igd(hass, + mock_local_ip, + mock_async_exception): """Test setup fails if we can't find an UPnP IGD.""" - mock_miniupnpc.selectigd.side_effect = Exception - - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': {} }) assert not result -@asyncio.coroutine -def test_setup_succeeds_if_specify_ip(hass, mock_miniupnpc): +async def test_setup_succeeds_if_specify_ip(hass, mock_msearch_first): """Test setup succeeds if we specify IP and can't find a local IP.""" with patch('homeassistant.components.upnp.get_local_ip', return_value='127.0.0.1'): - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { 'local_ip': '192.168.0.10' } }) assert result + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_no_config_maps_hass_local_to_remote_port(hass, mock_miniupnpc): +async def test_no_config_maps_hass_local_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test by default we map local to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { - 'upnp': { - 'local_ip': '192.168.0.10' - } + result = await async_setup_component(hass, 'upnp', { + 'upnp': {} }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert host == '192.168.0.10' - assert external == 8123 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 8123, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_hass_to_remote_port(hass, mock_miniupnpc): +async def test_map_hass_to_remote_port(hass, + mock_local_ip, + mock_msearch_first): """Test mapping hass to remote port.""" - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': { 'hass': 1000 } @@ -102,41 +141,38 @@ def test_map_hass_to_remote_port(hass, mock_miniupnpc): }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 1 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 1 + mock_service.mock_add_port_mapping.assert_called_once_with( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') -@asyncio.coroutine -def test_map_internal_to_remote_ports(hass, mock_miniupnpc): +async def test_map_internal_to_remote_ports(hass, + mock_local_ip, + mock_msearch_first): """Test mapping local to remote ports.""" ports = OrderedDict() ports['hass'] = 1000 ports[1883] = 3883 - result = yield from async_setup_component(hass, 'upnp', { + result = await async_setup_component(hass, 'upnp', { 'upnp': { - 'local_ip': '192.168.0.10', 'ports': ports } }) assert result - assert len(mock_miniupnpc.addportmapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[0][1] - assert external == 1000 - assert internal == 8123 + mock_service = hass.data[DATA_UPNP].peep_first_service() + assert len(mock_service.mock_add_port_mapping.mock_calls) == 2 - external, _, host, internal, _, _ = \ - mock_miniupnpc.addportmapping.mock_calls[1][1] - assert external == 3883 - assert internal == 1883 + mock_service.mock_add_port_mapping.assert_any_call( + 8123, 1000, '192.168.0.10', 'TCP', desc='Home Assistant') + mock_service.mock_add_port_mapping.assert_any_call( + 1883, 3883, '192.168.0.10', 'TCP', desc='Home Assistant') hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - yield from hass.async_block_till_done() - assert len(mock_miniupnpc.deleteportmapping.mock_calls) == 2 - assert mock_miniupnpc.deleteportmapping.mock_calls[0][1][0] == 1000 - assert mock_miniupnpc.deleteportmapping.mock_calls[1][1][0] == 3883 + await hass.async_block_till_done() + assert len(mock_service.mock_delete_port_mapping.mock_calls) == 2 + + mock_service.mock_delete_port_mapping.assert_any_call(1000, 'TCP') + mock_service.mock_delete_port_mapping.assert_any_call(3883, 'TCP') diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7a15ed28f97..b6bfa430fd2 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,6 +2,7 @@ import ctypes import os import shutil +import json from unittest.mock import patch, PropertyMock import pytest @@ -353,7 +354,7 @@ class TestTTS(object): demo_data = tts.SpeechManager.write_tags( "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3", demo_data, self.demo_provider, - "I person is on front of your door.", 'en', None) + "AI person is in front of your door.", 'en', None) assert req.status_code == 200 assert req.content == demo_data @@ -562,3 +563,46 @@ class TestTTS(object): req = requests.get(url) assert req.status_code == 200 assert req.content == demo_data + + def test_setup_component_and_web_get_url(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'platform': 'demo', + 'message': "I person is on front of your door."} + + req = requests.post(url, data=json.dumps(data)) + assert req.status_code == 200 + response = json.loads(req.text) + assert response.get('url') == (("{}/api/tts_proxy/265944c108cbb00b2a62" + "1be5930513e03a0bb2cd_en_-_demo.mp3") + .format(self.hass.config.api.base_url)) + + def test_setup_component_and_web_get_url_bad_config(self): + """Setup the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', + } + } + + with assert_setup_component(1, tts.DOMAIN): + setup_component(self.hass, tts.DOMAIN, config) + + self.hass.start() + + url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) + data = {'message': "I person is on front of your door."} + + req = requests.post(url, data=data) + assert req.status_code == 400 diff --git a/tests/components/vacuum/test_dyson.py b/tests/components/vacuum/test_dyson.py index 186a2271a73..8a4e6d57b91 100644 --- a/tests/components/vacuum/test_dyson.py +++ b/tests/components/vacuum/test_dyson.py @@ -118,7 +118,6 @@ class DysonTest(unittest.TestCase): component3 = Dyson360EyeDevice(device3) self.assertEqual(component.name, "Device_Vacuum") self.assertTrue(component.is_on) - self.assertEqual(component.icon, "mdi:roomba") self.assertEqual(component.status, "Cleaning") self.assertEqual(component2.status, "Unknown") self.assertEqual(component.battery_level, 85) diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index d8dac11f6a0..0bc6a7601dc 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -7,6 +7,8 @@ import unittest from unittest.mock import patch, Mock from datetime import timedelta +import pytest + import homeassistant.core as ha import homeassistant.loader as loader from homeassistant.exceptions import PlatformNotReady @@ -19,7 +21,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, MockModule, mock_coro, - async_fire_time_changed, MockEntity) + async_fire_time_changed, MockEntity, MockConfigEntry) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -333,3 +335,75 @@ def test_setup_dependencies_platform(hass): assert 'test_component' in hass.config.components assert 'test_component2' in hass.config.components assert 'test_domain.test_component' in hass.config.components + + +async def test_setup_entry(hass): + """Test setup entry calls async_setup_entry on platform.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + p_hass, p_entry, p_add_entities = mock_setup_entry.mock_calls[0][1] + assert p_hass is hass + assert p_entry is entry + + +async def test_setup_entry_platform_not_exist(hass): + """Test setup entry fails if platform doesnt exist.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='non_existing') + + assert (await component.async_setup_entry(entry)) is False + + +async def test_setup_entry_fails_duplicate(hass): + """Test we don't allow setting up a config entry twice.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + + with pytest.raises(ValueError): + await component.async_setup_entry(entry) + + +async def test_unload_entry_resets_platform(hass): + """Test unloading an entry removes all entities.""" + mock_setup_entry = Mock(return_value=mock_coro(True)) + loader.set_component( + 'test_domain.entry_domain', + MockPlatform(async_setup_entry=mock_setup_entry)) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + assert await component.async_setup_entry(entry) + assert len(mock_setup_entry.mock_calls) == 1 + add_entities = mock_setup_entry.mock_calls[0][1][2] + add_entities([MockEntity()]) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + + assert await component.async_unload_entry(entry) + assert len(hass.states.async_entity_ids()) == 0 + + +async def test_unload_entry_fails_if_never_loaded(hass): + """.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + entry = MockConfigEntry(domain='entry_domain') + + with pytest.raises(ValueError): + await component.async_unload_entry(entry) diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 8c085e4abb1..2018cb27541 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -5,6 +5,7 @@ import unittest from unittest.mock import patch, Mock, MagicMock from datetime import timedelta +from homeassistant.exceptions import PlatformNotReady import homeassistant.loader as loader from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import ( @@ -15,7 +16,7 @@ import homeassistant.util.dt as dt_util from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity, MockEntityPlatform) + MockEntity, MockEntityPlatform, MockConfigEntry, mock_coro) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -511,3 +512,72 @@ async def test_entity_registry_updates(hass): state = hass.states.get('test_domain.world') assert state.name == 'after update' + + +async def test_setup_entry(hass): + """Test we can setup an entry.""" + async_setup_entry = Mock(return_value=mock_coro(True)) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + entity_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(entity_platform.domain, config_entry.domain) + assert full_name in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + + +async def test_setup_entry_platform_not_ready(hass, caplog): + """Test when an entry is not ready yet.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + full_name = '{}.{}'.format(ent_platform.domain, config_entry.domain) + assert full_name not in hass.config.components + assert len(async_setup_entry.mock_calls) == 1 + assert 'Platform test not ready yet' in caplog.text + assert len(mock_call_later.mock_calls) == 1 + + +async def test_reset_cancels_retry_setup(hass): + """Test that resetting a platform will cancel scheduled a setup retry.""" + async_setup_entry = Mock(side_effect=PlatformNotReady) + platform = MockPlatform( + async_setup_entry=async_setup_entry + ) + config_entry = MockConfigEntry() + ent_platform = MockEntityPlatform( + hass, + platform_name=config_entry.domain, + platform=platform + ) + + with patch.object(entity_platform, 'async_call_later') as mock_call_later: + assert not await ent_platform.async_setup_entry(config_entry) + + assert len(mock_call_later.mock_calls) == 1 + assert len(mock_call_later.return_value.mock_calls) == 0 + assert ent_platform._async_cancel_retry_setup is not None + + await ent_platform.async_reset() + + assert len(mock_call_later.return_value.mock_calls) == 1 + assert ent_platform._async_cancel_retry_setup is None diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 650b98509d0..2dfcb2a58e5 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -149,6 +149,74 @@ class TestHelpersTemplate(unittest.TestCase): '{{ log(%s, %s) | round(1) }}' % (value, base), self.hass).render()) + def test_sine(self): + """Test sine.""" + tests = [ + (0, '0.0'), + (math.pi / 2, '1.0'), + (math.pi, '0.0'), + (math.pi * 1.5, '-1.0'), + (math.pi / 10, '0.309') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sin | round(3) }}' % value, + self.hass).render()) + + def test_cos(self): + """Test cosine.""" + tests = [ + (0, '1.0'), + (math.pi / 2, '0.0'), + (math.pi, '-1.0'), + (math.pi * 1.5, '-0.0'), + (math.pi / 10, '0.951') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | cos | round(3) }}' % value, + self.hass).render()) + + def test_tan(self): + """Test tangent.""" + tests = [ + (0, '0.0'), + (math.pi, '-0.0'), + (math.pi / 180 * 45, '1.0'), + (math.pi / 180 * 90, '1.633123935319537e+16'), + (math.pi / 180 * 135, '-1.0') + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | tan | round(3) }}' % value, + self.hass).render()) + + def test_sqrt(self): + """Test square root.""" + tests = [ + (0, '0.0'), + (1, '1.0'), + (2, '1.414'), + (10, '3.162'), + (100, '10.0'), + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | sqrt | round(3) }}' % value, + self.hass).render()) + def test_strptime(self): """Test the parse timestamp method.""" tests = [ diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 5b1ec3b8ec0..94b1dcb47da 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3,9 +3,8 @@ import asyncio from unittest.mock import MagicMock, patch, mock_open import pytest -import voluptuous as vol -from homeassistant import config_entries, loader +from homeassistant import config_entries, loader, data_entry_flow from homeassistant.setup import async_setup_component from tests.common import MockModule, mock_coro, MockConfigEntry @@ -100,7 +99,7 @@ def test_add_entry_calls_setup_entry(hass, manager): 'comp', MockModule('comp', async_setup_entry=mock_setup_entry)) - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 @@ -112,7 +111,7 @@ def test_add_entry_calls_setup_entry(hass, manager): 'token': 'supersecret' }) - with patch.dict(config_entries.HANDLERS, {'comp': TestFlow}): + with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}): yield from manager.flow.async_init('comp') yield from hass.async_block_till_done() @@ -152,7 +151,7 @@ def test_domains_gets_uniques(manager): @asyncio.coroutine def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" - class TestFlow(config_entries.ConfigFlowHandler): + class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 @asyncio.coroutine @@ -167,7 +166,7 @@ def test_saving_and_loading(hass): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): yield from hass.config_entries.flow.async_init('test') - class Test2Flow(config_entries.ConfigFlowHandler): + class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @asyncio.coroutine @@ -212,180 +211,37 @@ def test_saving_and_loading(hass): assert orig.source == loaded.source -####################### -# FLOW MANAGER TESTS # -####################### +async def test_forward_entry_sets_up_component(hass): + """Test we setup the component entry is forwarded to.""" + entry = MockConfigEntry(domain='original') -@asyncio.coroutine -def test_configure_reuses_handler_instance(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - handle_count = 0 + mock_original_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + 'original', + MockModule('original', async_setup_entry=mock_original_setup_entry)) - @asyncio.coroutine - def async_step_init(self, user_input=None): - self.handle_count += 1 - return self.async_show_form( - errors={'base': str(self.handle_count)}, - step_id='init') + mock_forwarded_setup_entry = MagicMock(return_value=mock_coro(True)) + loader.set_component( + 'forwarded', + MockModule('forwarded', async_setup_entry=mock_forwarded_setup_entry)) - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['errors']['base'] == '1' - form = yield from manager.flow.async_configure(form['flow_id']) - assert form['errors']['base'] == '2' - assert len(manager.flow.async_progress()) == 1 - assert len(manager.async_entries()) == 0 + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') + assert len(mock_original_setup_entry.mock_calls) == 0 + assert len(mock_forwarded_setup_entry.mock_calls) == 1 -@asyncio.coroutine -def test_configure_two_steps(manager): - """Test that we reuse instances.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 1 +async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass): + """Test we do not setup entry if component setup fails.""" + entry = MockConfigEntry(domain='original') - @asyncio.coroutine - def async_step_init(self, user_input=None): - if user_input is not None: - self.init_data = user_input - return self.async_step_second() - return self.async_show_form( - step_id='init', - data_schema=vol.Schema([str]) - ) + mock_setup = MagicMock(return_value=mock_coro(False)) + mock_setup_entry = MagicMock() + loader.set_component('forwarded', MockModule( + 'forwarded', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry, + )) - @asyncio.coroutine - def async_step_second(self, user_input=None): - if user_input is not None: - return self.async_create_entry( - title='Test Entry', - data=self.init_data + user_input - ) - return self.async_show_form( - step_id='second', - data_schema=vol.Schema([str]) - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - - with pytest.raises(vol.Invalid): - form = yield from manager.flow.async_configure( - form['flow_id'], 'INCORRECT-DATA') - - form = yield from manager.flow.async_configure( - form['flow_id'], ['INIT-DATA']) - form = yield from manager.flow.async_configure( - form['flow_id'], ['SECOND-DATA']) - assert form['type'] == config_entries.RESULT_TYPE_CREATE_ENTRY - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - entry = manager.async_entries()[0] - assert entry.domain == 'test' - assert entry.data == ['INIT-DATA', 'SECOND-DATA'] - - -@asyncio.coroutine -def test_show_form(manager): - """Test that abort removes the flow from progress.""" - schema = vol.Schema({ - vol.Required('username'): str, - vol.Required('password'): str - }) - - class TestFlow(config_entries.ConfigFlowHandler): - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_show_form( - step_id='init', - data_schema=schema, - errors={ - 'username': 'Should be unique.' - } - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['type'] == 'form' - assert form['data_schema'] is schema - assert form['errors'] == { - 'username': 'Should be unique.' - } - - -@asyncio.coroutine -def test_abort_removes_instance(manager): - """Test that abort removes the flow from progress.""" - class TestFlow(config_entries.ConfigFlowHandler): - is_new = True - - @asyncio.coroutine - def async_step_init(self, user_input=None): - old = self.is_new - self.is_new = False - return self.async_abort(reason=str(old)) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - form = yield from manager.flow.async_init('test') - assert form['reason'] == 'True' - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 0 - - -@asyncio.coroutine -def test_create_saves_data(manager): - """Test creating a config entry.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 5 - - @asyncio.coroutine - def async_step_init(self, user_input=None): - return self.async_create_entry( - title='Test Title', - data='Test Data' - ) - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init('test') - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'Test Title' - assert entry.data == 'Test Data' - assert entry.source == config_entries.SOURCE_USER - - -@asyncio.coroutine -def test_discovery_init_flow(manager): - """Test a flow initialized by discovery.""" - class TestFlow(config_entries.ConfigFlowHandler): - VERSION = 5 - - @asyncio.coroutine - def async_step_discovery(self, info): - return self.async_create_entry(title=info['id'], data=info) - - data = { - 'id': 'hello', - 'token': 'secret' - } - - with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from manager.flow.async_init( - 'test', source=config_entries.SOURCE_DISCOVERY, data=data) - assert len(manager.flow.async_progress()) == 0 - assert len(manager.async_entries()) == 1 - - entry = manager.async_entries()[0] - assert entry.version == 5 - assert entry.domain == 'test' - assert entry.title == 'hello' - assert entry.data == data - assert entry.source == config_entries.SOURCE_DISCOVERY + await hass.config_entries.async_forward_entry_setup(entry, 'forwarded') + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 0 diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py new file mode 100644 index 00000000000..2767e206c30 --- /dev/null +++ b/tests/test_data_entry_flow.py @@ -0,0 +1,192 @@ +"""Test the flow classes.""" +import pytest +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.util.decorator import Registry + + +@pytest.fixture +def manager(): + """Return a flow manager.""" + handlers = Registry() + entries = [] + + async def async_create_flow(handler_name): + handler = handlers.get(handler_name) + + if handler is None: + raise data_entry_flow.UnknownHandler + + return handler() + + async def async_add_entry(result): + entries.append(result) + + manager = data_entry_flow.FlowManager( + None, async_create_flow, async_add_entry) + manager.mock_created_entries = entries + manager.mock_reg_handler = handlers.register + return manager + + +async def test_configure_reuses_handler_instance(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + handle_count = 0 + + async def async_step_init(self, user_input=None): + self.handle_count += 1 + return self.async_show_form( + errors={'base': str(self.handle_count)}, + step_id='init') + + form = await manager.async_init('test') + assert form['errors']['base'] == '1' + form = await manager.async_configure(form['flow_id']) + assert form['errors']['base'] == '2' + assert len(manager.async_progress()) == 1 + assert len(manager.mock_created_entries) == 0 + + +async def test_configure_two_steps(manager): + """Test that we reuse instances.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, user_input=None): + if user_input is not None: + self.init_data = user_input + return await self.async_step_second() + return self.async_show_form( + step_id='init', + data_schema=vol.Schema([str]) + ) + + async def async_step_second(self, user_input=None): + if user_input is not None: + return self.async_create_entry( + title='Test Entry', + data=self.init_data + user_input + ) + return self.async_show_form( + step_id='second', + data_schema=vol.Schema([str]) + ) + + form = await manager.async_init('test') + + with pytest.raises(vol.Invalid): + form = await manager.async_configure( + form['flow_id'], 'INCORRECT-DATA') + + form = await manager.async_configure( + form['flow_id'], ['INIT-DATA']) + form = await manager.async_configure( + form['flow_id'], ['SECOND-DATA']) + assert form['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + result = manager.mock_created_entries[0] + assert result['handler'] == 'test' + assert result['data'] == ['INIT-DATA', 'SECOND-DATA'] + + +async def test_show_form(manager): + """Test that abort removes the flow from progress.""" + schema = vol.Schema({ + vol.Required('username'): str, + vol.Required('password'): str + }) + + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + async def async_step_init(self, user_input=None): + return self.async_show_form( + step_id='init', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + form = await manager.async_init('test') + assert form['type'] == 'form' + assert form['data_schema'] is schema + assert form['errors'] == { + 'username': 'Should be unique.' + } + + +async def test_abort_removes_instance(manager): + """Test that abort removes the flow from progress.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + is_new = True + + async def async_step_init(self, user_input=None): + old = self.is_new + self.is_new = False + return self.async_abort(reason=str(old)) + + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + form = await manager.async_init('test') + assert form['reason'] == 'True' + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 0 + + +async def test_create_saves_data(manager): + """Test creating a config entry.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_init(self, user_input=None): + return self.async_create_entry( + title='Test Title', + data='Test Data' + ) + + await manager.async_init('test') + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['handler'] == 'test' + assert entry['title'] == 'Test Title' + assert entry['data'] == 'Test Data' + assert entry['source'] == data_entry_flow.SOURCE_USER + + +async def test_discovery_init_flow(manager): + """Test a flow initialized by discovery.""" + @manager.mock_reg_handler('test') + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_discovery(self, info): + return self.async_create_entry(title=info['id'], data=info) + + data = { + 'id': 'hello', + 'token': 'secret' + } + + await manager.async_init( + 'test', source=data_entry_flow.SOURCE_DISCOVERY, data=data) + assert len(manager.async_progress()) == 0 + assert len(manager.mock_created_entries) == 1 + + entry = manager.mock_created_entries[0] + assert entry['version'] == 5 + assert entry['handler'] == 'test' + assert entry['title'] == 'hello' + assert entry['data'] == data + assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY