diff --git a/.coveragerc b/.coveragerc index bbed9b7e742..b7f2961f14d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -27,6 +27,7 @@ omit = homeassistant/components/ambient_station/* homeassistant/components/amcrest/* homeassistant/components/android_ip_webcam/* + homeassistant/components/androidtv/* homeassistant/components/apcupsd/* homeassistant/components/apiai/* homeassistant/components/apple_tv/* @@ -68,6 +69,7 @@ omit = homeassistant/components/camera/xiaomi.py homeassistant/components/camera/yi.py homeassistant/components/cast/* + homeassistant/components/cisco_mobility_express/device_tracker.py homeassistant/components/climate/coolmaster.py homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py @@ -109,6 +111,7 @@ omit = homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/bt_smarthub.py homeassistant/components/device_tracker/cisco_ios.py + homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py @@ -138,6 +141,7 @@ omit = homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubee.py homeassistant/components/device_tracker/ubus.py + homeassistant/components/device_tracker/xfinity.py homeassistant/components/digital_ocean/* homeassistant/components/dominos/* homeassistant/components/doorbird/* @@ -154,6 +158,7 @@ omit = homeassistant/components/elkm1/* homeassistant/components/emoncms_history/* homeassistant/components/emulated_hue/upnp.py + homeassistant/components/enigma2/media_player.py homeassistant/components/enocean/* homeassistant/components/envisalink/* homeassistant/components/esphome/__init__.py @@ -233,7 +238,7 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/light/lw12wifi.py homeassistant/components/light/mystrom.py - homeassistant/components/light/nanoleaf_aurora.py + homeassistant/components/light/nanoleaf.py homeassistant/components/light/niko_home_control.py homeassistant/components/light/opple.py homeassistant/components/light/osramlightify.py @@ -280,7 +285,6 @@ omit = homeassistant/components/media_player/dunehd.py homeassistant/components/media_player/emby.py homeassistant/components/media_player/epson.py - homeassistant/components/media_player/firetv.py homeassistant/components/media_player/frontier_silicon.py homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/gstreamer.py @@ -632,6 +636,7 @@ omit = homeassistant/components/thingspeak/* homeassistant/components/thinkingcleaner/* homeassistant/components/tibber/* + homeassistant/components/tof/sensor.py homeassistant/components/toon/* homeassistant/components/tplink_lte/* homeassistant/components/tradfri/* @@ -697,4 +702,4 @@ exclude_lines = # Don't complain if tests don't hit defensive assertion code: raise AssertionError - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 53cc6960fc3..ecdbddf5b5d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,7 @@ +## Breaking Change: + + + ## Description: diff --git a/.travis.yml b/.travis.yml index be00f989290..0461d182232 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,18 @@ sudo: false +dist: xenial addons: apt: + sources: + - sourceline: "ppa:jonathonf/ffmpeg-4" packages: - libudev-dev + - libavformat-dev + - libavcodec-dev + - libavdevice-dev + - libavutil-dev + - libswscale-dev + - libswresample-dev + - libavfilter-dev matrix: fast_finish: true include: @@ -19,15 +29,12 @@ matrix: env: TOXENV=py36 - python: "3.7" env: TOXENV=py37 - dist: xenial - python: "3.8-dev" env: TOXENV=py38 - dist: xenial if: branch = dev AND type = push allow_failures: - python: "3.8-dev" env: TOXENV=py38 - dist: xenial cache: directories: diff --git a/CODEOWNERS b/CODEOWNERS index ac8f98a11b0..a795c4c3151 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/binary_sensor/threshold.py @fabaff homeassistant/components/binary_sensor/uptimerobot.py @ludeeus +homeassistant/components/camera/push.py @dgomes homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/coolmaster.py @OnFreund homeassistant/components/climate/ephember.py @ttroy50 @@ -62,12 +63,13 @@ homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/device_tracker/traccar.py @ludeeus -homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/device_tracker/synology_srm.py @aerialls +homeassistant/components/device_tracker/xfinity.py @cisasteelersfan homeassistant/components/light/lifx_legacy.py @amelchio homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/light/yeelightsunflower.py @lindsaymarkward @@ -95,6 +97,7 @@ homeassistant/components/sensor/bitcoin.py @fabaff homeassistant/components/sensor/cpuspeed.py @fabaff homeassistant/components/sensor/cups.py @fabaff homeassistant/components/sensor/darksky.py @fabaff +homeassistant/components/sensor/discogs.py @thibmaek homeassistant/components/sensor/file.py @fabaff homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/fixer.py @fabaff @@ -112,6 +115,7 @@ homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff homeassistant/components/sensor/moon.py @fabaff homeassistant/components/sensor/netdata.py @fabaff +homeassistant/components/sensor/nmbs.py @thibmaek homeassistant/components/sensor/nsw_fuel_station.py @nickw444 homeassistant/components/sensor/pi_hole.py @fabaff homeassistant/components/sensor/pollen.py @bachya diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index bb90f296468..9e4b9d09d78 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -100,9 +100,21 @@ class AuthManager: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) + def get_auth_provider(self, provider_type: str, provider_id: str) \ + -> Optional[AuthProvider]: + """Return an auth provider, None if not found.""" + return self._providers.get((provider_type, provider_id)) + + def get_auth_providers(self, provider_type: str) \ + -> List[AuthProvider]: + """Return a List of auth provider of one type, Empty if not found.""" + return [provider + for (p_type, _), provider in self._providers.items() + if p_type == provider_type] + def get_auth_mfa_module(self, module_id: str) \ -> Optional[MultiFactorAuthModule]: - """Return an multi-factor auth module, None if not found.""" + """Return a multi-factor auth module, None if not found.""" return self._mfa_modules.get(module_id) async def async_get_users(self) -> List[models.User]: @@ -113,6 +125,11 @@ class AuthManager: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_owner(self) -> Optional[models.User]: + """Retrieve the owner.""" + users = await self.async_get_users() + return next((user for user in users if user.is_owner), None) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: """Retrieve all groups.""" return await self._store.async_get_group(group_id) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index c6078e03f63..a64c14454a6 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -11,13 +11,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models -from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from .const import GROUP_ID_ADMIN, GROUP_ID_USER, GROUP_ID_READ_ONLY from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 STORAGE_KEY = 'auth' GROUP_NAME_ADMIN = 'Administrators' +GROUP_NAME_USER = "Users" GROUP_NAME_READ_ONLY = 'Read Only' @@ -38,6 +39,7 @@ class AuthStore: self._perm_lookup = None # type: Optional[PermissionLookup] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) + self._lock = asyncio.Lock() async def async_get_groups(self) -> List[models.Group]: """Retrieve all users.""" @@ -272,8 +274,16 @@ class AuthStore: async def _async_load(self) -> None: """Load the users.""" - [ent_reg, data] = await asyncio.gather( + async with self._lock: + if self._users is not None: + return + await self._async_load_task() + + async def _async_load_task(self) -> None: + """Load the users.""" + [ent_reg, dev_reg, data] = await asyncio.gather( self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.device_registry.async_get_registry(), self._store.async_load(), ) @@ -282,7 +292,9 @@ class AuthStore: if self._users is not None: return - self._perm_lookup = perm_lookup = PermissionLookup(ent_reg) + self._perm_lookup = perm_lookup = PermissionLookup( + ent_reg, dev_reg + ) if data is None: self._set_defaults() @@ -297,6 +309,7 @@ class AuthStore: # 1. Data from a recent version which has a single group without policy # 2. Data from old version which has no groups has_admin_group = False + has_user_group = False has_read_only_group = False group_without_policy = None @@ -314,6 +327,13 @@ class AuthStore: policy = system_policies.ADMIN_POLICY system_generated = True + elif group_dict['id'] == GROUP_ID_USER: + has_user_group = True + + name = GROUP_NAME_USER + policy = system_policies.USER_POLICY + system_generated = True + elif group_dict['id'] == GROUP_ID_READ_ONLY: has_read_only_group = True @@ -361,6 +381,10 @@ class AuthStore: read_only_group = _system_read_only_group() groups[read_only_group.id] = read_only_group + if not has_user_group: + user_group = _system_user_group() + groups[user_group.id] = user_group + for user_dict in data['users']: # Collect the users group. user_groups = [] @@ -475,7 +499,7 @@ class AuthStore: 'name': group.name } # type: Dict[str, Any] - if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): + if not group.system_generated: g_dict['policy'] = group.policy groups.append(g_dict) @@ -528,6 +552,8 @@ class AuthStore: groups = OrderedDict() # type: Dict[str, models.Group] admin_group = _system_admin_group() groups[admin_group.id] = admin_group + user_group = _system_user_group() + groups[user_group.id] = user_group read_only_group = _system_read_only_group() groups[read_only_group.id] = read_only_group self._groups = groups @@ -543,6 +569,16 @@ def _system_admin_group() -> models.Group: ) +def _system_user_group() -> models.Group: + """Create system user group.""" + return models.Group( + name=GROUP_NAME_USER, + id=GROUP_ID_USER, + policy=system_policies.USER_POLICY, + system_generated=True, + ) + + def _system_read_only_group() -> models.Group: """Create read only group.""" return models.Group( diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 519669ead85..ef2d54ccbab 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -5,4 +5,5 @@ ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) GROUP_ID_ADMIN = 'system-admin' +GROUP_ID_USER = 'system-users' GROUP_ID_READ_ONLY = 'system-read-only' diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 0073c952648..3d7fc80307e 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,12 +1,14 @@ """Entity permissions.""" -from functools import wraps -from typing import Callable, List, Union # noqa: F401 +from collections import OrderedDict +from typing import Callable, Optional # noqa: F401 import voluptuous as vol from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT from .models import PermissionLookup -from .types import CategoryType, ValueType +from .types import CategoryType, SubCategoryDict, ValueType +# pylint: disable=unused-import +from .util import SubCatLookupType, lookup_all, compile_policy # noqa SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(POLICY_READ): True, @@ -15,6 +17,7 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ })) ENTITY_DOMAINS = 'domains' +ENTITY_AREAS = 'area_ids' ENTITY_DEVICE_IDS = 'device_ids' ENTITY_ENTITY_IDS = 'entity_ids' @@ -24,148 +27,65 @@ ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, })) -def _entity_allowed(schema: ValueType, key: str) \ - -> Union[bool, None]: - """Test if an entity is allowed based on the keys.""" - if schema is None or isinstance(schema, bool): - return schema - assert isinstance(schema, dict) - return schema.get(key) +def _lookup_domain(perm_lookup: PermissionLookup, + domains_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by domain.""" + return domains_dict.get(entity_id.split(".", 1)[0]) + + +def _lookup_area(perm_lookup: PermissionLookup, area_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by area.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + device_entry = perm_lookup.device_registry.async_get( + entity_entry.device_id + ) + + if device_entry is None or device_entry.area_id is None: + return None + + return area_dict.get(device_entry.area_id) + + +def _lookup_device(perm_lookup: PermissionLookup, + devices_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by device.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return devices_dict.get(entity_entry.device_id) + + +def _lookup_entity_id(perm_lookup: PermissionLookup, + entities_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permission by entity id.""" + return entities_dict.get(entity_id) def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \ -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" - # None, Empty Dict, False - if not policy: - def apply_policy_deny_all(entity_id: str, key: str) -> bool: - """Decline all.""" - return False + subcategories = OrderedDict() # type: SubCatLookupType + subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id + subcategories[ENTITY_DEVICE_IDS] = _lookup_device + subcategories[ENTITY_AREAS] = _lookup_area + subcategories[ENTITY_DOMAINS] = _lookup_domain + subcategories[SUBCAT_ALL] = lookup_all - return apply_policy_deny_all - - if policy is True: - def apply_policy_allow_all(entity_id: str, key: str) -> bool: - """Approve all.""" - return True - - return apply_policy_allow_all - - assert isinstance(policy, dict) - - domains = policy.get(ENTITY_DOMAINS) - device_ids = policy.get(ENTITY_DEVICE_IDS) - entity_ids = policy.get(ENTITY_ENTITY_IDS) - all_entities = policy.get(SUBCAT_ALL) - - funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] - - # The order of these functions matter. The more precise are at the top. - # If a function returns None, they cannot handle it. - # If a function returns a boolean, that's the result to return. - - # Setting entity_ids to a boolean is final decision for permissions - # So return right away. - if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, key: str) -> bool: - """Test if allowed entity_id.""" - return entity_ids # type: ignore - - return allowed_entity_id_bool - - if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed entity_id.""" - return _entity_allowed( - entity_ids.get(entity_id), key) # type: ignore - - funcs.append(allowed_entity_id_dict) - - if isinstance(device_ids, bool): - def allowed_device_id_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed device_id.""" - return device_ids - - funcs.append(allowed_device_id_bool) - - elif device_ids is not None: - def allowed_device_id_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed device_id.""" - entity_entry = perm_lookup.entity_registry.async_get(entity_id) - - if entity_entry is None or entity_entry.device_id is None: - return None - - return _entity_allowed( - device_ids.get(entity_entry.device_id), key # type: ignore - ) - - funcs.append(allowed_device_id_dict) - - if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return domains - - funcs.append(allowed_domain_bool) - - elif domains is not None: - def allowed_domain_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), key) # type: ignore - - funcs.append(allowed_domain_dict) - - if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return all_entities - funcs.append(allowed_all_entities_bool) - - elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return _entity_allowed(all_entities, key) - funcs.append(allowed_all_entities_dict) - - # Can happen if no valid subcategories specified - if not funcs: - def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: - """Decline all.""" - return False - - return apply_policy_deny_all_2 - - if len(funcs) == 1: - func = funcs[0] - - @wraps(func) - def apply_policy_func(entity_id: str, key: str) -> bool: - """Apply a single policy function.""" - return func(entity_id, key) is True - - return apply_policy_func - - def apply_policy_funcs(entity_id: str, key: str) -> bool: - """Apply several policy functions.""" - for func in funcs: - result = func(entity_id, key) - if result is not None: - return result - return False - - return apply_policy_funcs + return compile_policy(policy, subcategories, perm_lookup) diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index 7ad7d5521c5..10a76a4ec73 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -8,6 +8,9 @@ if TYPE_CHECKING: from homeassistant.helpers import ( # noqa entity_registry as ent_reg, ) + from homeassistant.helpers import ( # noqa + device_registry as dev_reg, + ) @attr.s(slots=True) @@ -15,3 +18,4 @@ class PermissionLookup: """Class to hold data for permission lookups.""" entity_registry = attr.ib(type='ent_reg.EntityRegistry') + device_registry = attr.ib(type='dev_reg.DeviceRegistry') diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py index 78da68c0d11..bf65c0a85a6 100644 --- a/homeassistant/auth/permissions/system_policies.py +++ b/homeassistant/auth/permissions/system_policies.py @@ -5,6 +5,10 @@ ADMIN_POLICY = { CAT_ENTITIES: True, } +USER_POLICY = { + CAT_ENTITIES: True, +} + READ_ONLY_POLICY = { CAT_ENTITIES: { SUBCAT_ALL: { diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 78d13b9679f..5479e59dcb6 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -10,9 +10,11 @@ ValueType = Union[ None ] +# Example: entities.domains = { light: … } +SubCategoryDict = Mapping[str, ValueType] + SubCategoryType = Union[ - # Example: entities.domains = { light: … } - Mapping[str, ValueType], + SubCategoryDict, bool, None ] diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py new file mode 100644 index 00000000000..d2d259fb32e --- /dev/null +++ b/homeassistant/auth/permissions/util.py @@ -0,0 +1,98 @@ +"""Helpers to deal with permissions.""" +from functools import wraps + +from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401 + +from .models import PermissionLookup +from .types import CategoryType, SubCategoryDict, ValueType + +LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], + Optional[ValueType]] +SubCatLookupType = Dict[str, LookupFunc] + + +def lookup_all(perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict, + object_id: str) -> ValueType: + """Look up permission for all.""" + # In case of ALL category, lookup_dict IS the schema. + return cast(ValueType, lookup_dict) + + +def compile_policy( + policy: CategoryType, subcategories: SubCatLookupType, + perm_lookup: PermissionLookup + ) -> Callable[[str, str], bool]: # noqa + """Compile policy into a function that tests policy. + Subcategories are mapping key -> lookup function, ordered by highest + priority first. + """ + # None, False, empty dict + if not policy: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all + + if policy is True: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: + """Approve all.""" + return True + + return apply_policy_allow_all + + assert isinstance(policy, dict) + + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] + + for key, lookup_func in subcategories.items(): + lookup_value = policy.get(key) + + # If any lookup value is `True`, it will always be positive + if isinstance(lookup_value, bool): + return lambda object_id, key: True + + if lookup_value is not None: + funcs.append(_gen_dict_test_func( + perm_lookup, lookup_func, lookup_value)) + + if len(funcs) == 1: + func = funcs[0] + + @wraps(func) + def apply_policy_func(object_id: str, key: str) -> bool: + """Apply a single policy function.""" + return func(object_id, key) is True + + return apply_policy_func + + def apply_policy_funcs(object_id: str, key: str) -> bool: + """Apply several policy functions.""" + for func in funcs: + result = func(object_id, key) + if result is not None: + return result + return False + + return apply_policy_funcs + + +def _gen_dict_test_func( + perm_lookup: PermissionLookup, + lookup_func: LookupFunc, + lookup_dict: SubCategoryDict + ) -> Callable[[str, str], Optional[bool]]: # noqa + """Generate a lookup function.""" + def test_value(object_id: str, key: str) -> Optional[bool]: + """Test if permission is allowed based on the keys.""" + schema = lookup_func( + perm_lookup, lookup_dict, object_id) # type: ValueType + + if schema is None or isinstance(schema, bool): + return schema + + assert isinstance(schema, dict) + + return schema.get(key) + + return test_value diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 6cdb12b7157..e85d831a325 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,27 +4,23 @@ Support Legacy API password auth provider. It will be removed when auth system production ready """ import hmac -from typing import Any, Dict, Optional, cast, TYPE_CHECKING +from typing import Any, Dict, Optional, cast import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow from .. import AuthManager from ..models import Credentials, UserMeta, User -if TYPE_CHECKING: - from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 - - -USER_SCHEMA = vol.Schema({ - vol.Required('username'): str, -}) - +AUTH_PROVIDER_TYPE = 'legacy_api_password' +CONF_API_PASSWORD = 'api_password' CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ + vol.Required(CONF_API_PASSWORD): cv.string, }, extra=vol.PREVENT_EXTRA) LEGACY_USER_NAME = 'Legacy API password user' @@ -34,40 +30,45 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -async def async_get_user(hass: HomeAssistant) -> User: - """Return the legacy API password user.""" +async def async_validate_password(hass: HomeAssistant, password: str)\ + -> Optional[User]: + """Return a user if password is valid. None if not.""" auth = cast(AuthManager, hass.auth) # type: ignore - found = None - - for prv in auth.auth_providers: - if prv.type == 'legacy_api_password': - found = prv - break - - if found is None: + providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE) + if not providers: raise ValueError('Legacy API password provider not found') - return await auth.async_get_or_create_user( - await found.async_get_or_create_credentials({}) - ) + try: + provider = cast(LegacyApiPasswordAuthProvider, providers[0]) + provider.async_validate_login(password) + return await auth.async_get_or_create_user( + await provider.async_get_or_create_credentials({}) + ) + except InvalidAuthError: + return None -@AUTH_PROVIDERS.register('legacy_api_password') +@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE) class LegacyApiPasswordAuthProvider(AuthProvider): - """Example auth provider based on hardcoded usernames and passwords.""" + """An auth provider support legacy api_password.""" DEFAULT_TITLE = 'Legacy API Password' + @property + def api_password(self) -> str: + """Return api_password.""" + return str(self.config[CONF_API_PASSWORD]) + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" return LegacyLoginFlow(self) @callback def async_validate_login(self, password: str) -> None: - """Validate a username and password.""" - hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + """Validate password.""" + api_password = str(self.config[CONF_API_PASSWORD]) - if not hmac.compare_digest(hass_http.api_password.encode('utf-8'), + if not hmac.compare_digest(api_password.encode('utf-8'), password.encode('utf-8')): raise InvalidAuthError @@ -99,12 +100,6 @@ class LegacyLoginFlow(LoginFlow): """Handle the step of the form.""" errors = {} - hass_http = getattr(self.hass, 'http', None) - if hass_http is None or not hass_http.api_password: - return self.async_abort( - reason='no_api_password_set' - ) - if user_input is not None: try: cast(LegacyApiPasswordAuthProvider, self._auth_provider)\ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eef36b026e1..d532d9cdb86 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -5,7 +5,7 @@ import os import sys from time import time from collections import OrderedDict -from typing import Any, Optional, Dict +from typing import Any, Optional, Dict, Set import voluptuous as vol @@ -28,8 +28,16 @@ ERROR_LOG_FILENAME = 'home-assistant.log' # hass.data key for logging information. DATA_LOGGING = 'logging' -FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', - 'logger', 'introduction', 'frontend', 'history'} +LOGGING_COMPONENT = {'logger', 'system_log'} + +FIRST_INIT_COMPONENT = { + 'recorder', + 'mqtt', + 'mqtt_eventstream', + 'introduction', + 'frontend', + 'history', +} def from_config_dict(config: Dict[str, Any], @@ -91,12 +99,12 @@ async def async_from_config_dict(config: Dict[str, Any], "This may cause issues") core_config = config.get(core.DOMAIN, {}) - has_api_password = bool(config.get('http', {}).get('api_password')) + api_password = config.get('http', {}).get('api_password') trusted_networks = config.get('http', {}).get('trusted_networks') try: await conf_util.async_process_ha_core_config( - hass, core_config, has_api_password, trusted_networks) + hass, core_config, api_password, trusted_networks) except vol.Invalid as config_err: conf_util.async_log_exception( config_err, 'homeassistant', core_config, hass) @@ -117,12 +125,9 @@ async def async_from_config_dict(config: Dict[str, Any], hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_load() + await hass.config_entries.async_initialize() - # Filter out the repeating and common config section [homeassistant] - components = set(key.split(' ')[0] for key in config.keys() - if key != core.DOMAIN) - components.update(hass.config_entries.async_domains()) + components = _get_components(hass, config) # Resolve all dependencies of all components. for component in list(components): @@ -144,17 +149,25 @@ async def async_from_config_dict(config: Dict[str, Any], _LOGGER.info("Home Assistant core initialized") + # stage 0, load logging components + for component in components: + if component in LOGGING_COMPONENT: + hass.async_create_task( + async_setup_component(hass, component, config)) + + await hass.async_block_till_done() + # stage 1 for component in components: - if component not in FIRST_INIT_COMPONENT: - continue - hass.async_create_task(async_setup_component(hass, component, config)) + if component in FIRST_INIT_COMPONENT: + hass.async_create_task( + async_setup_component(hass, component, config)) await hass.async_block_till_done() # stage 2 for component in components: - if component in FIRST_INIT_COMPONENT: + if component in FIRST_INIT_COMPONENT or component in LOGGING_COMPONENT: continue hass.async_create_task(async_setup_component(hass, component, config)) @@ -375,3 +388,21 @@ async def async_mount_local_lib_path(config_dir: str) -> str: if lib_dir not in sys.path: sys.path.insert(0, lib_dir) return deps_dir + + +@core.callback +def _get_components(hass: core.HomeAssistant, + config: Dict[str, Any]) -> Set[str]: + """Get components to set up.""" + # Filter out the repeating and common config section [homeassistant] + components = set(key.split(' ')[0] for key in config.keys() + if key != core.DOMAIN) + + # Add config entry domains + components.update(hass.config_entries.async_domains()) # type: ignore + + # Make sure the Hass.io component is loaded + if 'HASSIO' in os.environ: + components.add('hassio') + + return components diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 8715f0baa96..533811e275d 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, @@ -70,7 +70,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: """Set up general services related to Home Assistant.""" async def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" - entity_ids = extract_entity_ids(hass, service) + entity_ids = await async_extract_entity_ids(hass, service) # Generic turn on/off method requires entity id if not entity_ids: @@ -166,6 +166,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: _LOGGER.error(err) return + # auth only processed during startup await conf_util.async_process_ha_core_config( hass, conf.get(ha.DOMAIN) or {}) diff --git a/homeassistant/components/air_quality/opensensemap.py b/homeassistant/components/air_quality/opensensemap.py index 8462e40be5b..5407f65a1d8 100644 --- a/homeassistant/components/air_quality/opensensemap.py +++ b/homeassistant/components/air_quality/opensensemap.py @@ -11,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['opensensemap-api==0.1.4'] +REQUIREMENTS = ['opensensemap-api==0.1.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 693c15fa424..9bee2b81d61 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -342,18 +342,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): ) @callback - def message_received(topic, payload, qos): + def message_received(msg): """Run when new MQTT message has been received.""" - if payload == self._payload_disarm: + if msg.payload == self._payload_disarm: self.async_alarm_disarm(self._code) - elif payload == self._payload_arm_home: + elif msg.payload == self._payload_arm_home: self.async_alarm_arm_home(self._code) - elif payload == self._payload_arm_away: + elif msg.payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) - elif payload == self._payload_arm_night: + elif msg.payload == self._payload_arm_night: self.async_alarm_arm_night(self._code) else: - _LOGGER.warning("Received unexpected payload: %s", payload) + _LOGGER.warning("Received unexpected payload: %s", msg.payload) return await mqtt.async_subscribe( diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 3b0725658d4..ba8155fde93 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.22'] +REQUIREMENTS = ['total_connect_client==0.24'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 986907622b1..cf26e42b056 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -131,6 +131,11 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): if code: self.hass.data[DATA_AD].send("{!s}3".format(code)) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if code: + self.hass.data[DATA_AD].send("{!s}33".format(code)) + def alarm_toggle_chime(self, code=None): """Send toggle chime command.""" if code: diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index f92fd6b187b..4c990d62d4b 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -89,7 +89,7 @@ async def async_setup(hass, config): async def async_handle_alert_service(service_call): """Handle calls to alert services.""" - alert_ids = service.extract_entity_ids(hass, service_call) + alert_ids = await service.async_extract_entity_ids(hass, service_call) for alert_id in alert_ids: for alert in entities: diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a856a3d8e82..c87b2c3f624 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,21 +1,21 @@ """Support for alexa Smart Home Skill API.""" import asyncio -from collections import OrderedDict -from datetime import datetime import json import logging import math +from collections import OrderedDict +from datetime import datetime from uuid import uuid4 import aiohttp import async_timeout +import homeassistant.core as ha +import homeassistant.util.color as color_util from homeassistant.components import ( alert, automation, binary_sensor, cover, fan, group, http, input_boolean, light, lock, media_player, scene, script, sensor, switch) from homeassistant.components.climate import const as climate -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_track_state_change from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, @@ -25,14 +25,14 @@ from homeassistant.const import ( SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET, SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) -import homeassistant.core as ha -import homeassistant.util.color as color_util +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.event import async_track_state_change from homeassistant.util.decorator import Registry from homeassistant.util.temperature import convert as convert_temperature +from .auth import Auth from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT -from .auth import Auth _LOGGER = logging.getLogger(__name__) @@ -1115,12 +1115,15 @@ class SmartHomeView(http.HomeAssistantView): the response. """ hass = request.app['hass'] + user = request[http.KEY_HASS_USER] message = await request.json() _LOGGER.debug("Received Alexa Smart Home request: %s", message) response = await async_handle_message( - hass, self.smart_home_config, message) + hass, self.smart_home_config, message, + context=ha.Context(user_id=user.id) + ) _LOGGER.debug("Sending Alexa Smart Home response: %s", response) return b'' if response is None else self.json(response) diff --git a/homeassistant/components/ambient_station/.translations/th.json b/homeassistant/components/ambient_station/.translations/th.json new file mode 100644 index 00000000000..9f08ed5f1a2 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/th.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "\u0e04\u0e35\u0e22\u0e4c API", + "app_key": "\u0e23\u0e2b\u0e31\u0e2a\u0e41\u0e2d\u0e1b\u0e1e\u0e25\u0e34\u0e40\u0e04\u0e0a\u0e31\u0e19" + }, + "title": "\u0e01\u0e23\u0e2d\u0e01\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 70f6ce9fbba..545415f9d5d 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -296,6 +296,7 @@ class AmbientStation: def __init__(self, hass, config_entry, client, monitored_conditions): """Initialize.""" self._config_entry = config_entry + self._entry_setup_complete = False self._hass = hass self._watchdog_listener = None self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY @@ -362,12 +363,18 @@ class AmbientStation: 'name', station['macAddress']), } - for component in ('binary_sensor', 'sensor'): - self._hass.async_create_task( - self._hass.config_entries.async_forward_entry_setup( - self._config_entry, component)) + # If the websocket disconnects and reconnects, the on_subscribed + # handler will get called again; in that case, we don't want to + # attempt forward setup of the config entry (because it will have + # already been done): + if not self._entry_setup_complete: + for component in ('binary_sensor', 'sensor'): + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component)) + self._entry_setup_complete = True - self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY self.client.websocket.on_connect(on_connect) self.client.websocket.on_data(on_data) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 49f11570b21..b976c1bd9d3 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['amcrest==1.2.3'] +REQUIREMENTS = ['amcrest==1.2.5'] DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 7c943b89734..f6c507e73f4 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,6 +1,10 @@ """Support for Amcrest IP cameras.""" +import asyncio import logging +from requests import RequestException +from urllib3.exceptions import ReadTimeoutError + from homeassistant.components.amcrest import ( DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT) from homeassistant.components.camera import Camera @@ -43,12 +47,20 @@ class AmcrestCam(Camera): self._stream_source = amcrest.stream_source self._resolution = amcrest.resolution self._token = self._auth = amcrest.authentication + self._snapshot_lock = asyncio.Lock() - def camera_image(self): + async def async_camera_image(self): """Return a still image response from the camera.""" - # Send the request to snap a picture and return raw jpg data - response = self._camera.snapshot(channel=self._resolution) - return response.data + async with self._snapshot_lock: + try: + # Send the request to snap a picture and return raw jpg data + response = await self.hass.async_add_executor_job( + self._camera.snapshot, self._resolution) + return response.data + except (RequestException, ReadTimeoutError, ValueError) as error: + _LOGGER.error( + 'Could not get camera image due to error %s', error) + return None async def handle_async_mjpeg_stream(self, request): """Return an MJPEG stream.""" @@ -85,3 +97,8 @@ class AmcrestCam(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._camera.rtsp_url(typeno=self._resolution) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py new file mode 100644 index 00000000000..fd108e05973 --- /dev/null +++ b/homeassistant/components/androidtv/__init__.py @@ -0,0 +1,6 @@ +""" +Support for functionality to interact with Android TV and Fire TV devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.androidtv/ +""" diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py new file mode 100644 index 00000000000..1282a40cac5 --- /dev/null +++ b/homeassistant/components/androidtv/media_player.py @@ -0,0 +1,454 @@ +""" +Support for functionality to interact with Android TV and Fire TV devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.androidtv/ +""" +import functools +import logging +import voluptuous as vol + +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA) +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) +from homeassistant.const import ( + ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, + CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, + STATE_STANDBY) +import homeassistant.helpers.config_validation as cv + +ANDROIDTV_DOMAIN = 'androidtv' + +REQUIREMENTS = ['androidtv==0.0.12'] + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_ANDROIDTV = SUPPORT_PAUSE | SUPPORT_PLAY | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_MUTE | \ + SUPPORT_VOLUME_STEP + +SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP + +CONF_ADBKEY = 'adbkey' +CONF_ADB_SERVER_IP = 'adb_server_ip' +CONF_ADB_SERVER_PORT = 'adb_server_port' +CONF_APPS = 'apps' +CONF_GET_SOURCES = 'get_sources' + +DEFAULT_NAME = 'Android TV' +DEFAULT_PORT = 5555 +DEFAULT_ADB_SERVER_PORT = 5037 +DEFAULT_GET_SOURCES = True +DEFAULT_DEVICE_CLASS = 'auto' + +DEVICE_ANDROIDTV = 'androidtv' +DEVICE_FIRETV = 'firetv' +DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] + +SERVICE_ADB_COMMAND = 'adb_command' + +SERVICE_ADB_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND): cv.string, +}) + + +def has_adb_files(value): + """Check that ADB key files exist.""" + priv_key = value + pub_key = '{}.pub'.format(value) + cv.isfile(pub_key) + return cv.isfile(priv_key) + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): + vol.In(DEVICE_CLASSES), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_ADBKEY): has_adb_files, + vol.Optional(CONF_ADB_SERVER_IP): cv.string, + vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): + cv.port, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, + vol.Optional(CONF_APPS, default=dict()): + vol.Schema({cv.string: cv.string}) +}) + +# Translate from `AndroidTV` / `FireTV` reported state to HA state. +ANDROIDTV_STATES = {'off': STATE_OFF, + 'idle': STATE_IDLE, + 'standby': STATE_STANDBY, + 'playing': STATE_PLAYING, + 'paused': STATE_PAUSED} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Android TV / Fire TV platform.""" + from androidtv import setup + + hass.data.setdefault(ANDROIDTV_DOMAIN, {}) + + host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) + + if CONF_ADB_SERVER_IP not in config: + # Use "python-adb" (Python ADB implementation) + if CONF_ADBKEY in config: + aftv = setup(host, config[CONF_ADBKEY], + device_class=config[CONF_DEVICE_CLASS]) + adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + + else: + aftv = setup(host, device_class=config[CONF_DEVICE_CLASS]) + adb_log = "" + else: + # Use "pure-python-adb" (communicate with ADB server) + aftv = setup(host, adb_server_ip=config[CONF_ADB_SERVER_IP], + adb_server_port=config[CONF_ADB_SERVER_PORT], + device_class=config[CONF_DEVICE_CLASS]) + adb_log = " using ADB server at {0}:{1}".format( + config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT]) + + if not aftv.available: + # Determine the name that will be used for the device in the log + if CONF_NAME in config: + device_name = config[CONF_NAME] + elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: + device_name = 'Android TV device' + elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: + device_name = 'Fire TV device' + else: + device_name = 'Android TV / Fire TV device' + + _LOGGER.warning("Could not connect to %s at %s%s", + device_name, host, adb_log) + return + + if host in hass.data[ANDROIDTV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", host) + else: + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice(aftv, config[CONF_NAME], + config[CONF_APPS]) + device_name = config[CONF_NAME] if CONF_NAME in config \ + else 'Android TV' + else: + device = FireTVDevice(aftv, config[CONF_NAME], config[CONF_APPS], + config[CONF_GET_SOURCES]) + device_name = config[CONF_NAME] if CONF_NAME in config \ + else 'Fire TV' + + add_entities([device]) + _LOGGER.debug("Setup %s at %s%s", device_name, host, adb_log) + hass.data[ANDROIDTV_DOMAIN][host] = device + + if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND): + return + + def service_adb_command(service): + """Dispatch service calls to target entities.""" + cmd = service.data.get(ATTR_COMMAND) + entity_id = service.data.get(ATTR_ENTITY_ID) + target_devices = [dev for dev in hass.data[ANDROIDTV_DOMAIN].values() + if dev.entity_id in entity_id] + + for target_device in target_devices: + output = target_device.adb_command(cmd) + + # log the output if there is any + if output: + _LOGGER.info("Output of command '%s' from '%s': %s", + cmd, target_device.entity_id, repr(output)) + + hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND, + service_adb_command, + schema=SERVICE_ADB_COMMAND_SCHEMA) + + +def adb_decorator(override_available=False): + """Send an ADB command if the device is available and catch exceptions.""" + def _adb_decorator(func): + """Wait if previous ADB commands haven't finished.""" + @functools.wraps(func) + def _adb_exception_catcher(self, *args, **kwargs): + # If the device is unavailable, don't do anything + if not self.available and not override_available: + return None + + try: + return func(self, *args, **kwargs) + except self.exceptions as err: + _LOGGER.error( + "Failed to execute an ADB command. ADB connection re-" + "establishing attempt in the next update. Error: %s", err) + self._available = False # pylint: disable=protected-access + return None + + return _adb_exception_catcher + + return _adb_decorator + + +class ADBDevice(MediaPlayerDevice): + """Representation of an Android TV or Fire TV device.""" + + def __init__(self, aftv, name, apps): + """Initialize the Android TV / Fire TV device.""" + from androidtv.constants import APPS, KEYS + + self.aftv = aftv + self._name = name + self._apps = APPS + self._apps.update(apps) + self._keys = KEYS + + # ADB exceptions to catch + if not self.aftv.adb_server_ip: + # Using "python-adb" (Python ADB implementation) + from adb.adb_protocol import (InvalidChecksumError, + InvalidCommandError, + InvalidResponseError) + from adb.usb_exceptions import TcpTimeoutException + + self.exceptions = (AttributeError, BrokenPipeError, TypeError, + ValueError, InvalidChecksumError, + InvalidCommandError, InvalidResponseError, + TcpTimeoutException) + else: + # Using "pure-python-adb" (communicate with ADB server) + self.exceptions = (ConnectionResetError,) + + # Property attributes + self._available = self.aftv.available + self._current_app = None + self._state = None + + @property + def app_id(self): + """Return the current app.""" + return self._current_app + + @property + def app_name(self): + """Return the friendly name of the current app.""" + return self._apps.get(self._current_app, self._current_app) + + @property + def available(self): + """Return whether or not the ADB connection is valid.""" + return self._available + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def should_poll(self): + """Device should be polled.""" + return True + + @property + def state(self): + """Return the state of the player.""" + return self._state + + @adb_decorator() + def media_play(self): + """Send play command.""" + self.aftv.media_play() + + @adb_decorator() + def media_pause(self): + """Send pause command.""" + self.aftv.media_pause() + + @adb_decorator() + def media_play_pause(self): + """Send play/pause command.""" + self.aftv.media_play_pause() + + @adb_decorator() + def turn_on(self): + """Turn on the device.""" + self.aftv.turn_on() + + @adb_decorator() + def turn_off(self): + """Turn off the device.""" + self.aftv.turn_off() + + @adb_decorator() + def media_previous_track(self): + """Send previous track command (results in rewind).""" + self.aftv.media_previous() + + @adb_decorator() + def media_next_track(self): + """Send next track command (results in fast-forward).""" + self.aftv.media_next() + + @adb_decorator() + def adb_command(self, cmd): + """Send an ADB command to an Android TV / Fire TV device.""" + key = self._keys.get(cmd) + if key: + return self.aftv.adb_shell('input keyevent {}'.format(key)) + + if cmd == 'GET_PROPERTIES': + return self.aftv.get_properties_dict() + + return self.aftv.adb_shell(cmd) + + +class AndroidTVDevice(ADBDevice): + """Representation of an Android TV device.""" + + def __init__(self, aftv, name, apps): + """Initialize the Android TV device.""" + super().__init__(aftv, name, apps) + + self._device = None + self._muted = None + self._device_properties = self.aftv.device_properties + self._unique_id = 'androidtv-{}-{}'.format( + name, self._device_properties['serialno']) + self._volume = None + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands. + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the `state`, `current_app`, and `running_apps`. + state, self._current_app, self._device, self._muted, self._volume = \ + self.aftv.update() + + self._state = ANDROIDTV_STATES[state] + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted + + @property + def source(self): + """Return the current playback device.""" + return self._device + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ANDROIDTV + + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + + @property + def volume_level(self): + """Return the volume level.""" + return self._volume + + @adb_decorator() + def media_stop(self): + """Send stop command.""" + self.aftv.media_stop() + + @adb_decorator() + def mute_volume(self, mute): + """Mute the volume.""" + self.aftv.mute_volume() + + @adb_decorator() + def volume_down(self): + """Send volume down command.""" + self.aftv.volume_down() + + @adb_decorator() + def volume_up(self): + """Send volume up command.""" + self.aftv.volume_up() + + +class FireTVDevice(ADBDevice): + """Representation of a Fire TV device.""" + + def __init__(self, aftv, name, apps, get_sources): + """Initialize the Fire TV device.""" + super().__init__(aftv, name, apps) + + self._get_sources = get_sources + self._running_apps = None + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands. + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the `state`, `current_app`, and `running_apps`. + state, self._current_app, self._running_apps = \ + self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES[state] + + @property + def source(self): + """Return the current app.""" + return self._current_app + + @property + def source_list(self): + """Return a list of running apps.""" + return self._running_apps + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_FIRETV + + @adb_decorator() + def media_stop(self): + """Send stop (back) command.""" + self.aftv.back() + + @adb_decorator() + def select_source(self, source): + """Select input source. + + If the source starts with a '!', then it will close the app instead of + opening it. + """ + if isinstance(source, str): + if not source.startswith('!'): + self.aftv.launch_app(source) + else: + self.aftv.stop_app(source[1:].lstrip()) diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml new file mode 100644 index 00000000000..78ff0a828f6 --- /dev/null +++ b/homeassistant/components/androidtv/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available Android TV and Fire TV services + +adb_command: + description: Send an ADB command to an Android TV / Fire TV device. + fields: + entity_id: + description: Name(s) of Android TV / Fire TV entities. + example: 'media_player.android_tv_living_room' + command: + description: Either a key command or an ADB shell command. + example: 'HOME' diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4ebe0ac8aaf..09f9b324bdd 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.components import apcupsd -from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES) +from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES, POWER_WATT) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -57,7 +57,7 @@ SENSOR_TYPES = { 'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'], 'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'], 'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'], - 'nompower': ['Nominal Output Power', 'W', 'mdi:flash'], + 'nompower': ['Nominal Output Power', POWER_WATT, 'mdi:flash'], 'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'], 'numxfers': ['Transfer Count', '', 'mdi:counter'], 'outcurnt': ['Output Current', 'A', 'mdi:flash'], @@ -93,7 +93,7 @@ INFERRED_UNITS = { ' Volts': 'V', ' Ampere': 'A', ' Volt-Ampere': 'VA', - ' Watts': 'W', + ' Watts': POWER_WATT, ' Hz': 'Hz', ' C': TEMP_CELSIUS, ' Percent Load Capacity': '%', diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7639ac621fe..beba17ee2ea 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -168,11 +168,11 @@ class APIDiscoveryView(HomeAssistantView): def get(self, request): """Get discovery information.""" hass = request.app['hass'] - needs_auth = hass.config.api.api_password is not None return self.json({ ATTR_BASE_URL: hass.config.api.base_url, ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_REQUIRES_API_PASSWORD: needs_auth, + # always needs authentication + ATTR_REQUIRES_API_PASSWORD: True, ATTR_VERSION: __version__, }) diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 3fc0e9d6476..9b004b5bc04 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.20'] +REQUIREMENTS = ['aioasuswrt==1.1.21'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/.translations/th.json new file mode 100644 index 00000000000..735b7e2fad5 --- /dev/null +++ b/homeassistant/components/auth/.translations/th.json @@ -0,0 +1,11 @@ +{ + "mfa_setup": { + "notify": { + "step": { + "setup": { + "title": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e01\u0e32\u0e23\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 2e74961d11b..19edfe5a618 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -127,6 +127,7 @@ import voluptuous as vol from homeassistant.auth.models import User, Credentials, \ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN +from homeassistant.loader import bind_hass from homeassistant.components import websocket_api from homeassistant.components.http import KEY_REAL_IP from homeassistant.components.http.auth import async_sign_path @@ -184,10 +185,18 @@ RESULT_TYPE_USER = 'user' _LOGGER = logging.getLogger(__name__) +@bind_hass +def create_auth_code(hass, client_id: str, user: User) -> str: + """Create an authorization code to fetch tokens.""" + return hass.data[DOMAIN](client_id, user) + + async def async_setup(hass, config): """Component to allow users to login.""" store_result, retrieve_result = _create_auth_code_store() + hass.data[DOMAIN] = store_result + hass.http.register_view(TokenView(retrieve_result)) hass.http.register_view(LinkUserView(retrieve_result)) @@ -450,6 +459,7 @@ async def websocket_current_user( 'id': user.id, 'name': user.name, 'is_owner': user.is_owner, + 'is_admin': user.is_admin, 'credentials': [{'auth_provider_type': c.auth_provider_type, 'auth_provider_id': c.auth_provider_id} for c in user.credentials], diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 30432a612a4..1437685692b 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,4 +1,5 @@ """Helpers to resolve client ID/secret.""" +import logging import asyncio from ipaddress import ip_address from html.parser import HTMLParser @@ -9,6 +10,8 @@ from aiohttp.client_exceptions import ClientError from homeassistant.util.network import is_local +_LOGGER = logging.getLogger(__name__) + async def verify_redirect_uri(hass, client_id, redirect_uri): """Verify that the client and redirect uri match.""" @@ -78,7 +81,8 @@ async def fetch_redirect_uris(hass, url): if chunks == 10: break - except (asyncio.TimeoutError, ClientError): + except (asyncio.TimeoutError, ClientError) as ex: + _LOGGER.error("Error while looking up redirect_uri %s: %s", url, ex) pass # Authorization endpoints verifying that a redirect_uri is allowed for use diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 35cf695f1e3..5a7b19ce4e3 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,7 +7,7 @@ import logging import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform -from homeassistant.core import CoreState +from homeassistant.core import CoreState, Context from homeassistant.loader import bind_hass from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, @@ -120,7 +120,7 @@ async def async_setup(hass, config): async def trigger_service_handler(service_call): """Handle automation triggers.""" tasks = [] - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): tasks.append(entity.async_trigger( service_call.data.get(ATTR_VARIABLES), skip_condition=True, @@ -133,7 +133,7 @@ async def async_setup(hass, config): """Handle automation turn on/off service calls.""" tasks = [] method = 'async_{}'.format(service_call.service) - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): tasks.append(getattr(entity, method)()) if tasks: @@ -142,7 +142,7 @@ async def async_setup(hass, config): async def toggle_service_handler(service_call): """Handle automation toggle service calls.""" tasks = [] - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): if entity.is_on: tasks.append(entity.async_turn_off()) else: @@ -280,15 +280,21 @@ class AutomationEntity(ToggleEntity, RestoreEntity): This method is a coroutine. """ - if skip_condition or self._cond_func(variables): - self.async_set_context(context) - self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { - ATTR_NAME: self._name, - ATTR_ENTITY_ID: self.entity_id, - }, context=context) - await self._async_action(self.entity_id, variables, context) - self._last_triggered = utcnow() - await self.async_update_ha_state() + if not skip_condition and not self._cond_func(variables): + return + + # Create a new context referring to the old context. + parent_id = None if context is None else context.id + trigger_context = Context(parent_id=parent_id) + + self.async_set_context(trigger_context) + self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { + ATTR_NAME: self._name, + ATTR_ENTITY_ID: self.entity_id, + }, context=trigger_context) + await self._async_action(self.entity_id, variables, trigger_context) + self._last_triggered = utcnow() + await self.async_update_ha_state() async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 5f52da745ee..ff89cd47024 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -29,18 +29,18 @@ async def async_trigger(hass, config, action, automation_info): encoding = config[CONF_ENCODING] or None @callback - def mqtt_automation_listener(msg_topic, msg_payload, qos): + def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" - if payload is None or payload == msg_payload: + if payload is None or payload == mqttmsg.payload: data = { 'platform': 'mqtt', - 'topic': msg_topic, - 'payload': msg_payload, - 'qos': qos, + 'topic': mqttmsg.topic, + 'payload': mqttmsg.payload, + 'qos': mqttmsg.qos, } try: - data['payload_json'] = json.loads(msg_payload) + data['payload_json'] = json.loads(mqttmsg.payload) except ValueError: pass diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 0d4e9631650..fe3d10ab72c 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.16.1'] +REQUIREMENTS = ['numpy==1.16.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 551ca835e78..6b547927af4 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -42,6 +42,7 @@ CONF_PROVINCE = 'province' CONF_WORKDAYS = 'workdays' CONF_EXCLUDES = 'excludes' CONF_OFFSET = 'days_offset' +CONF_ADD_HOLIDAYS = 'add_holidays' # By default, Monday - Friday are workdays DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] @@ -59,6 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PROVINCE): cv.string, vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), + vol.Optional(CONF_ADD_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]), }) @@ -72,6 +74,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): workdays = config.get(CONF_WORKDAYS) excludes = config.get(CONF_EXCLUDES) days_offset = config.get(CONF_OFFSET) + add_holidays = config.get(CONF_ADD_HOLIDAYS) year = (get_date(datetime.today()) + timedelta(days=days_offset)).year obj_holidays = getattr(holidays, country)(years=year) @@ -92,6 +95,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): province, country) return + # Add custom holidays + try: + obj_holidays.append(add_holidays) + except TypeError: + _LOGGER.debug("No custom holidays or invalid holidays") + _LOGGER.debug("Found the following holidays for your configuration:") for date, name in sorted(obj_holidays.items()): _LOGGER.debug("%s %s", date, name) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 474f9594610..95d6dba50c3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -28,6 +28,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import ( # noqa PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP) +from homeassistant.components.stream import request_stream +from homeassistant.components.stream.const import ( + OUTPUT_FORMATS, FORMAT_CONTENT_TYPE) from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv @@ -39,11 +45,14 @@ _LOGGER = logging.getLogger(__name__) SERVICE_ENABLE_MOTION = 'enable_motion_detection' SERVICE_DISABLE_MOTION = 'disable_motion_detection' SERVICE_SNAPSHOT = 'snapshot' +SERVICE_PLAY_STREAM = 'play_stream' SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + '.{}' ATTR_FILENAME = 'filename' +ATTR_MEDIA_PLAYER = 'media_player' +ATTR_FORMAT = 'format' STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' @@ -69,6 +78,11 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ vol.Required(ATTR_FILENAME): cv.template }) +CAMERA_SERVICE_PLAY_STREAM = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP), + vol.Optional(ATTR_FORMAT, default='hls'): vol.In(OUTPUT_FORMATS), +}) + WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, @@ -176,6 +190,7 @@ async def async_setup(hass, config): WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, SCHEMA_WS_CAMERA_THUMBNAIL ) + hass.components.websocket_api.async_register_command(ws_camera_stream) await component.async_setup(config) @@ -209,6 +224,10 @@ async def async_setup(hass, config): SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, async_handle_snapshot_service ) + component.async_register_entity_service( + SERVICE_PLAY_STREAM, CAMERA_SERVICE_PLAY_STREAM, + async_handle_play_stream_service + ) return True @@ -273,6 +292,11 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return 0.5 + @property + def stream_source(self): + """Return the source of the stream.""" + return None + def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() @@ -473,6 +497,33 @@ async def websocket_camera_thumbnail(hass, connection, msg): msg['id'], 'image_fetch_failed', 'Unable to fetch image')) +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/stream', + vol.Required('entity_id'): cv.entity_id, + vol.Optional('format', default='hls'): vol.In(OUTPUT_FORMATS), +}) +async def ws_camera_stream(hass, connection, msg): + """Handle get camera stream websocket command. + + Async friendly. + """ + try: + camera = _get_camera_from_entity_id(hass, msg['entity_id']) + + if not camera.stream_source: + raise HomeAssistantError("{} does not support play stream service" + .format(camera.entity_id)) + + fmt = msg['format'] + url = request_stream(hass, camera.stream_source, fmt=fmt) + connection.send_result(msg['id'], {'url': url}) + except HomeAssistantError as ex: + _LOGGER.error(ex) + connection.send_error( + msg['id'], 'start_stream_failed', str(ex)) + + async def async_handle_snapshot_service(camera, service): """Handle snapshot services calls.""" hass = camera.hass @@ -500,3 +551,25 @@ async def async_handle_snapshot_service(camera, service): _write_image, snapshot_file, image) except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + + +async def async_handle_play_stream_service(camera, service_call): + """Handle play stream services calls.""" + if not camera.stream_source: + raise HomeAssistantError("{} does not support play stream service" + .format(camera.entity_id)) + + hass = camera.hass + fmt = service_call.data[ATTR_FORMAT] + entity_ids = service_call.data[ATTR_MEDIA_PLAYER] + + url = request_stream(hass, camera.stream_source, fmt=fmt) + data = { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url), + ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt] + } + + await hass.services.async_call( + DOMAIN_MP, SERVICE_PLAY_MEDIA, data, + blocking=True, context=service_call.context) diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index f89e5ff29c2..c8d6721ac18 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -28,12 +28,14 @@ _LOGGER = logging.getLogger(__name__) CONF_CONTENT_TYPE = 'content_type' CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' CONF_STILL_IMAGE_URL = 'still_image_url' +CONF_STREAM_SOURCE = 'stream_source' CONF_FRAMERATE = 'framerate' DEFAULT_NAME = 'Generic Camera' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STILL_IMAGE_URL): cv.template, + vol.Optional(CONF_STREAM_SOURCE, default=None): vol.Any(None, cv.string), vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, @@ -62,6 +64,7 @@ class GenericCamera(Camera): self._authentication = device_info.get(CONF_AUTHENTICATION) self._name = device_info.get(CONF_NAME) self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._stream_source = device_info[CONF_STREAM_SOURCE] self._still_image_url.hass = hass self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] self._frame_interval = 1 / device_info[CONF_FRAMERATE] @@ -128,7 +131,7 @@ class GenericCamera(Camera): url, auth=self._auth) self._last_image = await response.read() except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image") + _LOGGER.error("Timeout getting image from: %s", self._name) return self._last_image except aiohttp.ClientError as err: _LOGGER.error("Error getting new camera image: %s", err) @@ -141,3 +144,8 @@ class GenericCamera(Camera): def name(self): """Return the name of this device.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._stream_source diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 8afd71abc26..3e6e4911d27 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -11,13 +11,11 @@ from datetime import timedelta import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \ - HTTP_HEADER_HA_AUTH +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util -from homeassistant.components.camera import async_get_still_stream REQUIREMENTS = ['pillow==5.4.1'] @@ -209,9 +207,6 @@ class ProxyCamera(Camera): or config.get(CONF_CACHE_IMAGES)) self._last_image_time = dt_util.utc_from_timestamp(0) self._last_image = None - self._headers = ( - {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} - if self.hass.config.api.api_password is not None else None) self._mode = config.get(CONF_MODE) def camera_image(self): @@ -252,7 +247,7 @@ class ProxyCamera(Camera): return await self.hass.components.camera.async_get_mjpeg_stream( request, self._proxied_camera) - return await async_get_still_stream( + return await self.hass.components.camera.async_get_still_stream( request, self._async_stream_image, self.content_type, self.frame_interval) diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 1cae5baf1cf..575f1fe76f7 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -38,6 +38,19 @@ snapshot: description: Template of a Filename. Variable is entity_id. example: '/tmp/snapshot_{{ entity_id }}' +play_stream: + description: Play camera stream on supported media player. + fields: + entity_id: + description: Name(s) of entities to stream from. + example: 'camera.living_room_camera' + media_player: + description: Name(s) of media player to stream to. + example: 'media_player.living_room_tv' + format: + description: (Optional) Stream format supported by media player. + example: 'hls' + local_file_update_file_path: description: Update the file_path for a local_file camera. fields: diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index c268c3533e0..74532a935fc 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pyxeoma==1.4.0'] +REQUIREMENTS = ['pyxeoma==1.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 1b3da200540..bc32b36c455 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -2,7 +2,7 @@ from homeassistant import config_entries from homeassistant.helpers import config_entry_flow -REQUIREMENTS = ['pychromecast==2.5.2'] +REQUIREMENTS = ['pychromecast==3.0.0'] DOMAIN = 'cast' diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 432290482f1..28373cc6c14 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -257,6 +257,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _async_setup_platform(hass, cfg, async_add_entities, None) for cfg in config]) if any([task.exception() for task in done]): + exceptions = [task.exception() for task in done] + for exception in exceptions: + _LOGGER.debug("Failed to setup chromecast", exc_info=exception) raise PlatformNotReady @@ -289,7 +292,7 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, if cast_device is not None: async_add_entities([cast_device]) - remove_handler = async_dispatcher_connect( + async_dispatcher_connect( hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered) # Re-play the callback for all past chromecasts, store the objects in # a list to avoid concurrent modification resulting in exception. @@ -306,8 +309,6 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, if info.friendly_name is None: _LOGGER.debug("Cannot retrieve detail information for chromecast" " %s, the device may not be online", info) - remove_handler() - raise PlatformNotReady hass.async_add_job(_discover_chromecast, hass, info) @@ -477,16 +478,10 @@ class CastDevice(MediaPlayerDevice): )) self._chromecast = chromecast self._status_listener = CastStatusListener(self, chromecast) - # Initialise connection status as connected because we can only - # register the connection listener *after* the initial connection - # attempt. If the initial connection failed, we would never reach - # this code anyway. - self._available = True + self._available = False self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status - _LOGGER.debug("[%s %s (%s:%s)] Connection successful!", - self.entity_id, self._cast_info.friendly_name, - self._cast_info.host, self._cast_info.port) + self._chromecast.start() self.async_schedule_update_ha_state() async def async_del_cast_info(self, cast_info): @@ -562,6 +557,10 @@ class CastDevice(MediaPlayerDevice): self.entity_id, self._cast_info.friendly_name, self._cast_info.host, self._cast_info.port, connection_status.status) + info = self._cast_info + if info.friendly_name is None and not info.is_audio_group: + # We couldn't find friendly_name when the cast was added, retry + self._cast_info = _fill_out_missing_chromecast_info(info) self._available = new_available self.schedule_update_ha_state() diff --git a/homeassistant/components/cisco_mobility_express/__init__.py b/homeassistant/components/cisco_mobility_express/__init__.py new file mode 100644 index 00000000000..625a71a5b05 --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/__init__.py @@ -0,0 +1 @@ +"""Component to embed Cisco Mobility Express.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py new file mode 100644 index 00000000000..60f8761aeeb --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -0,0 +1,83 @@ +"""Support for Cisco Mobility Express.""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL) + + +REQUIREMENTS = ['ciscomobilityexpress==0.1.2'] + + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def get_scanner(hass, config): + """Validate the configuration and return a Cisco ME scanner.""" + from ciscomobilityexpress.ciscome import CiscoMobilityExpress + config = config[DOMAIN] + + controller = CiscoMobilityExpress( + config[CONF_HOST], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config.get(CONF_SSL), + config.get(CONF_VERIFY_SSL)) + if not controller.is_logged_in(): + return None + return CiscoMEDeviceScanner(controller) + + +class CiscoMEDeviceScanner(DeviceScanner): + """This class scans for devices associated to a Cisco ME controller.""" + + def __init__(self, controller): + """Initialize the scanner.""" + self.controller = controller + self.last_results = {} + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.macaddr for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + name = next(( + result.clId for result in self.last_results + if result.macaddr == device), None) + return name + + def get_extra_attributes(self, device): + """ + Get extra attributes of a device. + + Some known extra attributes that may be returned in the device tuple + include SSID, PT (eg 802.11ac), devtype (eg iPhone 7) among others. + """ + device = next(( + result for result in self.last_results + if result.macaddr == device), None) + return device._asdict() + + def _update_info(self): + """Check the Cisco ME controller for devices.""" + self.last_results = self.controller.get_associated_devices() + _LOGGER.debug("Cisco Mobility Express controller returned:" + " %s", self.last_results) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index e213ae09de6..364c452bf4d 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,4 +1,4 @@ -"""Proides the constants needed for component.""" +"""Provides the constants needed for component.""" ATTR_AUX_HEAT = 'aux_heat' ATTR_AWAY_MODE = 'away_mode' diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py index 9884d81a199..220c073ef80 100644 --- a/homeassistant/components/climate/ephember.py +++ b/homeassistant/components/climate/ephember.py @@ -117,7 +117,8 @@ class EphEmberThermostat(ClimateDevice): @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - mode = self._ember.get_zone_mode(self._zone_name) + from pyephember.pyephember import ZoneMode + mode = ZoneMode(self._zone['mode']) return self.map_mode_eph_hass(mode) @property diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index c7c5973fb86..43a26c27ce1 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -184,7 +184,8 @@ class EQ3BTSmartThermostat(ClimateDevice): def update(self): """Update the data from the thermostat.""" - from bluepy.btle import BTLEException # pylint: disable=import-error + # pylint: disable=import-error,no-name-in-module + from bluepy.btle import BTLEException try: self._thermostat.update() except BTLEException as ex: diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index dbcbebff566..a76f992a76a 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -273,6 +273,11 @@ class HoneywellUSThermostat(ClimateDevice): """Return the current temperature.""" return self._device.current_temperature + @property + def current_humidity(self): + """Return the current humidity.""" + return self._device.current_humidity + @property def target_temperature(self): """Return the temperature we try to reach.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c427657c76d..ff1b2344ac8 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,43 +1,39 @@ """Component to integrate the Home Assistant cloud.""" -from datetime import datetime, timedelta -import json import logging -import os import voluptuous as vol -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, - CONF_MODE, CONF_NAME) -from homeassistant.helpers import entityfilter, config_validation as cv -from homeassistant.util import dt as dt_util +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c +from homeassistant.const import ( + CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, entityfilter +from homeassistant.loader import bind_hass +from homeassistant.util.aiohttp import MockRequest -from . import http_api, iot, auth_api, prefs, cloudhooks -from .const import CONFIG_DIR, DOMAIN, SERVERS +from . import http_api +from .const import ( + CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES, + CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, + CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL, + CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, + CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) +from .prefs import CloudPreferences -REQUIREMENTS = ['warrant==0.6.1'] +REQUIREMENTS = ['hass-nabucasa==0.8'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) -CONF_ALEXA = 'alexa' -CONF_ALIASES = 'aliases' -CONF_COGNITO_CLIENT_ID = 'cognito_client_id' -CONF_ENTITY_CONFIG = 'entity_config' -CONF_FILTER = 'filter' -CONF_GOOGLE_ACTIONS = 'google_actions' -CONF_RELAYER = 'relayer' -CONF_USER_POOL_ID = 'user_pool_id' -CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' -CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' -CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' +DEFAULT_MODE = MODE_PROD -DEFAULT_MODE = 'production' -DEPENDENCIES = ['http'] +SERVICE_REMOTE_CONNECT = 'remote_connect' +SERVICE_REMOTE_DISCONNECT = 'remote_disconnect' -MODE_DEV = 'development' ALEXA_ENTITY_SCHEMA = vol.Schema({ vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, @@ -52,7 +48,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({ }) ASSISTANT_SCHEMA = vol.Schema({ - vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA, }) ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ @@ -63,205 +59,140 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, }) +# pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.In([MODE_DEV] + list(SERVERS)), + vol.In([MODE_DEV, MODE_PROD]), # Change to optional when we include real servers vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, - vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, - vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, - vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, + vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(), + vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(), + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(), + vol.Optional(CONF_REMOTE_API_URL): vol.Url(), + vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), }, extra=vol.ALLOW_EXTRA) +class CloudNotAvailable(HomeAssistantError): + """Raised when an action requires the cloud but it's not available.""" + + +@bind_hass +@callback +def async_is_logged_in(hass) -> bool: + """Test if user is logged in.""" + return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in + + +@bind_hass +@callback +def async_active_subscription(hass) -> bool: + """Test if user has an active subscription.""" + return \ + async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired + + +@bind_hass +async def async_create_cloudhook(hass, webhook_id: str) -> str: + """Create a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) + return hook['cloudhook_url'] + + +@bind_hass +async def async_delete_cloudhook(hass, webhook_id: str) -> None: + """Delete a cloudhook.""" + if DOMAIN not in hass.data: + raise CloudNotAvailable + + await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) + + +@bind_hass +@callback +def async_remote_ui_url(hass) -> str: + """Get the remote UI URL.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return "https://" + hass.data[DOMAIN].remote.instance_domain + + +def is_cloudhook_request(request): + """Test if a request came from a cloudhook. + + Async friendly. + """ + return isinstance(request, MockRequest) + + async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" + from hass_nabucasa import Cloud + from .client import CloudClient + + # Process configs if DOMAIN in config: kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} + # Alexa/Google custom config alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({}) - if CONF_GOOGLE_ACTIONS not in kwargs: - kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) + # Cloud settings + prefs = CloudPreferences(hass) + await prefs.async_initialize() - kwargs[CONF_ALEXA] = alexa_sh.Config( - endpoint=None, - async_get_access_token=None, - should_expose=alexa_conf[CONF_FILTER], - entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), - ) + # Cloud user + if not prefs.cloud_user: + user = await hass.auth.async_create_system_user( + 'Home Assistant Cloud', [GROUP_ID_ADMIN]) + await prefs.async_update(cloud_user=user.id) + + # Initialize Cloud + websession = hass.helpers.aiohttp_client.async_get_clientsession() + client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) + cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) + + async def _startup(event): + """Startup event.""" + await cloud.start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup) + + async def _shutdown(event): + """Shutdown event.""" + await cloud.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + async def _service_handler(service): + """Handle service for cloud.""" + if service.service == SERVICE_REMOTE_CONNECT: + await cloud.remote.connect() + await prefs.async_update(remote_enabled=True) + elif service.service == SERVICE_REMOTE_DISCONNECT: + await cloud.remote.disconnect() + await prefs.async_update(remote_enabled=False) + + hass.services.async_register( + DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler) + hass.services.async_register( + DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) - cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - await auth_api.async_setup(hass, cloud) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) await http_api.async_setup(hass) + hass.async_create_task(hass.helpers.discovery.async_load_platform( + 'binary_sensor', DOMAIN, {}, config)) return True - - -class Cloud: - """Store the configuration of the cloud connection.""" - - def __init__(self, hass, mode, alexa, google_actions, - cognito_client_id=None, user_pool_id=None, region=None, - relayer=None, google_actions_sync_url=None, - subscription_info_url=None, cloudhook_create_url=None): - """Create an instance of Cloud.""" - self.hass = hass - self.mode = mode - self.alexa_config = alexa - self.google_actions_user_conf = google_actions - self._gactions_config = None - self.prefs = prefs.CloudPreferences(hass) - self.id_token = None - self.access_token = None - self.refresh_token = None - self.iot = iot.CloudIoT(self) - self.cloudhooks = cloudhooks.Cloudhooks(self) - - if mode == MODE_DEV: - self.cognito_client_id = cognito_client_id - self.user_pool_id = user_pool_id - self.region = region - self.relayer = relayer - self.google_actions_sync_url = google_actions_sync_url - self.subscription_info_url = subscription_info_url - self.cloudhook_create_url = cloudhook_create_url - - else: - info = SERVERS[mode] - - self.cognito_client_id = info['cognito_client_id'] - self.user_pool_id = info['user_pool_id'] - self.region = info['region'] - self.relayer = info['relayer'] - self.google_actions_sync_url = info['google_actions_sync_url'] - self.subscription_info_url = info['subscription_info_url'] - self.cloudhook_create_url = info['cloudhook_create_url'] - - @property - def is_logged_in(self): - """Get if cloud is logged in.""" - return self.id_token is not None - - @property - def subscription_expired(self): - """Return a boolean if the subscription has expired.""" - return dt_util.utcnow() > self.expiration_date + timedelta(days=7) - - @property - def expiration_date(self): - """Return the subscription expiration as a UTC datetime object.""" - return datetime.combine( - dt_util.parse_date(self.claims['custom:sub-exp']), - datetime.min.time()).replace(tzinfo=dt_util.UTC) - - @property - def claims(self): - """Return the claims from the id token.""" - return self._decode_claims(self.id_token) - - @property - def user_info_path(self): - """Get path to the stored auth.""" - return self.path('{}_auth.json'.format(self.mode)) - - @property - def gactions_config(self): - """Return the Google Assistant config.""" - if self._gactions_config is None: - conf = self.google_actions_user_conf - - def should_expose(entity): - """If an entity should be exposed.""" - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - - return conf['filter'](entity.entity_id) - - self._gactions_config = ga_h.Config( - should_expose=should_expose, - allow_unlock=self.prefs.google_allow_unlock, - agent_user_id=self.claims['cognito:username'], - entity_config=conf.get(CONF_ENTITY_CONFIG), - ) - - return self._gactions_config - - def path(self, *parts): - """Get config path inside cloud dir. - - Async friendly. - """ - return self.hass.config.path(CONFIG_DIR, *parts) - - async def fetch_subscription_info(self): - """Fetch subscription info.""" - await self.hass.async_add_executor_job(auth_api.check_token, self) - websession = self.hass.helpers.aiohttp_client.async_get_clientsession() - return await websession.get( - self.subscription_info_url, headers={ - 'authorization': self.id_token - }) - - async def logout(self): - """Close connection and remove all credentials.""" - await self.iot.disconnect() - - self.id_token = None - self.access_token = None - self.refresh_token = None - self._gactions_config = None - - await self.hass.async_add_job( - lambda: os.remove(self.user_info_path)) - - def write_user_info(self): - """Write user info to a file.""" - with open(self.user_info_path, 'wt') as file: - file.write(json.dumps({ - 'id_token': self.id_token, - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - }, indent=4)) - - async def async_start(self, _): - """Start the cloud component.""" - def load_config(): - """Load config.""" - # Ensure config dir exists - path = self.hass.config.path(CONFIG_DIR) - if not os.path.isdir(path): - os.mkdir(path) - - user_info = self.user_info_path - if not os.path.isfile(user_info): - return None - - with open(user_info, 'rt') as file: - return json.loads(file.read()) - - info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize() - - if info is None: - return - - self.id_token = info['id_token'] - self.access_token = info['access_token'] - self.refresh_token = info['refresh_token'] - - self.hass.async_create_task(self.iot.connect()) - - def _decode_claims(self, token): # pylint: disable=no-self-use - """Decode the claims in a token.""" - from jose import jwt - return jwt.get_unverified_claims(token) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py deleted file mode 100644 index 6019dac87b9..00000000000 --- a/homeassistant/components/cloud/auth_api.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Package to communicate with the authentication API.""" -import asyncio -import logging -import random - - -_LOGGER = logging.getLogger(__name__) - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UserNotFound(CloudError): - """Raised when a user is not found.""" - - -class UserNotConfirmed(CloudError): - """Raised when a user has not confirmed email yet.""" - - -class PasswordChangeRequired(CloudError): - """Raised when a password change is required.""" - - # https://github.com/PyCQA/pylint/issues/1085 - # pylint: disable=useless-super-delegation - def __init__(self, message='Password change required.'): - """Initialize a password change required error.""" - super().__init__(message) - - -class UnknownError(CloudError): - """Raised when an unknown error occurs.""" - - -AWS_EXCEPTIONS = { - 'UserNotFoundException': UserNotFound, - 'NotAuthorizedException': Unauthenticated, - 'UserNotConfirmedException': UserNotConfirmed, - 'PasswordResetRequiredException': PasswordChangeRequired, -} - - -async def async_setup(hass, cloud): - """Configure the auth api.""" - refresh_task = None - - async def handle_token_refresh(): - """Handle Cloud access token refresh.""" - sleep_time = 5 - sleep_time = random.randint(2400, 3600) - while True: - try: - await asyncio.sleep(sleep_time) - await hass.async_add_executor_job(renew_access_token, cloud) - except CloudError as err: - _LOGGER.error("Can't refresh cloud token: %s", err) - except asyncio.CancelledError: - # Task is canceled, stop it. - break - - sleep_time = random.randint(3100, 3600) - - async def on_connect(): - """When the instance is connected.""" - nonlocal refresh_task - refresh_task = hass.async_create_task(handle_token_refresh()) - - async def on_disconnect(): - """When the instance is disconnected.""" - nonlocal refresh_task - refresh_task.cancel() - - cloud.iot.register_on_connect(on_connect) - cloud.iot.register_on_disconnect(on_disconnect) - - -def _map_aws_exception(err): - """Map AWS exception to our exceptions.""" - ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) - return ex(err.response['Error']['Message']) - - -def register(cloud, email, password): - """Register a new account.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud) - # Workaround for bug in Warrant. PR with fix: - # https://github.com/capless/warrant/pull/82 - cognito.add_base_attributes() - try: - cognito.register(email, password) - - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def resend_email_confirm(cloud, email): - """Resend email confirmation.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud, username=email) - - try: - cognito.client.resend_confirmation_code( - Username=email, - ClientId=cognito.client_id - ) - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def forgot_password(cloud, email): - """Initialize forgotten password flow.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud, username=email) - - try: - cognito.initiate_forgot_password() - - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def login(cloud, email, password): - """Log user in and fetch certificate.""" - cognito = _authenticate(cloud, email, password) - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.refresh_token = cognito.refresh_token - cloud.write_user_info() - - -def check_token(cloud): - """Check that the token is valid and verify if needed.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito( - cloud, - access_token=cloud.access_token, - refresh_token=cloud.refresh_token) - - try: - if cognito.check_token(): - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.write_user_info() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def renew_access_token(cloud): - """Renew access token.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito( - cloud, - access_token=cloud.access_token, - refresh_token=cloud.refresh_token) - - try: - cognito.renew_access_token() - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.write_user_info() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def _authenticate(cloud, email, password): - """Log in and return an authenticated Cognito instance.""" - from botocore.exceptions import ClientError, EndpointConnectionError - from warrant.exceptions import ForceChangePasswordException - - assert not cloud.is_logged_in, 'Cannot login if already logged in.' - - cognito = _cognito(cloud, username=email) - - try: - cognito.authenticate(password=password) - return cognito - - except ForceChangePasswordException: - raise PasswordChangeRequired() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def _cognito(cloud, **kwargs): - """Get the client credentials.""" - import botocore - import boto3 - from warrant import Cognito - - cognito = Cognito( - user_pool_id=cloud.user_pool_id, - client_id=cloud.cognito_client_id, - user_pool_region=cloud.region, - **kwargs - ) - cognito.client = boto3.client( - 'cognito-idp', - region_name=cloud.region, - config=botocore.config.Config( - signature_version=botocore.UNSIGNED - ) - ) - return cognito diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py new file mode 100644 index 00000000000..874c3420c58 --- /dev/null +++ b/homeassistant/components/cloud/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for Home Assistant Cloud binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN + +DEPENDENCIES = ['cloud'] + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the cloud binary sensors.""" + if discovery_info is None: + return + cloud = hass.data[DOMAIN] + + async_add_entities([CloudRemoteBinary(cloud)]) + + +class CloudRemoteBinary(BinarySensorDevice): + """Representation of an Cloud Remote UI Connection binary sensor.""" + + def __init__(self, cloud): + """Initialize the binary sensor.""" + self.cloud = cloud + self._unsub_dispatcher = None + + @property + def name(self) -> str: + """Return the name of the binary sensor, if any.""" + return "Remote UI" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "cloud-remote-ui-connectivity" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.cloud.remote.is_connected + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.cloud.remote.certificate is not None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + async def async_added_to_hass(self): + """Register update dispatcher.""" + @callback + def async_state_update(data): + """Update callback.""" + self.async_write_ha_state() + + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update) + + async def async_will_remove_from_hass(self): + """Register update dispatcher.""" + if self._unsub_dispatcher is not None: + self._unsub_dispatcher() + self._unsub_dispatcher = None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py new file mode 100644 index 00000000000..da89f8331a9 --- /dev/null +++ b/homeassistant/components/cloud/client.py @@ -0,0 +1,198 @@ +"""Interface implementation for cloud client.""" +import asyncio +from pathlib import Path +from typing import Any, Dict + +import aiohttp +from hass_nabucasa.client import CloudClient as Interface + +from homeassistant.core import callback +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import ( + helpers as ga_h, smart_home as ga) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util.aiohttp import MockRequest + +from . import utils +from .const import ( + CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) +from .prefs import CloudPreferences + + +class CloudClient(Interface): + """Interface class for Home Assistant Cloud.""" + + def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, + websession: aiohttp.ClientSession, + alexa_config: Dict[str, Any], google_config: Dict[str, Any]): + """Initialize client interface to Cloud.""" + self._hass = hass + self._prefs = prefs + self._websession = websession + self._alexa_user_config = alexa_config + self._google_user_config = google_config + + self._alexa_config = None + self._google_config = None + + @property + def base_path(self) -> Path: + """Return path to base dir.""" + return Path(self._hass.config.config_dir) + + @property + def prefs(self) -> CloudPreferences: + """Return Cloud preferences.""" + return self._prefs + + @property + def loop(self) -> asyncio.BaseEventLoop: + """Return client loop.""" + return self._hass.loop + + @property + def websession(self) -> aiohttp.ClientSession: + """Return client session for aiohttp.""" + return self._websession + + @property + def aiohttp_runner(self) -> aiohttp.web.AppRunner: + """Return client webinterface aiohttp application.""" + return self._hass.http.runner + + @property + def cloudhooks(self) -> Dict[str, Dict[str, str]]: + """Return list of cloudhooks.""" + return self._prefs.cloudhooks + + @property + def remote_autostart(self) -> bool: + """Return true if we want start a remote connection.""" + return self._prefs.remote_enabled + + @property + def alexa_config(self) -> alexa_sh.Config: + """Return Alexa config.""" + if not self._alexa_config: + alexa_conf = self._alexa_user_config + + self._alexa_config = alexa_sh.Config( + endpoint=None, + async_get_access_token=None, + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + + return self._alexa_config + + @property + def google_config(self) -> ga_h.Config: + """Return Google config.""" + if not self._google_config: + google_conf = self._google_user_config + + def should_expose(entity): + """If an entity should be exposed.""" + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + return google_conf['filter'](entity.entity_id) + + self._google_config = ga_h.Config( + should_expose=should_expose, + allow_unlock=self._prefs.google_allow_unlock, + entity_config=google_conf.get(CONF_ENTITY_CONFIG), + ) + + return self._google_config + + @property + def google_user_config(self) -> Dict[str, Any]: + """Return google action user config.""" + return self._google_user_config + + async def cleanups(self) -> None: + """Cleanup some stuff after logout.""" + self._alexa_config = None + self._google_config = None + + @callback + def user_message(self, identifier: str, title: str, message: str) -> None: + """Create a message for user to UI.""" + self._hass.components.persistent_notification.async_create( + message, title, identifier + ) + + @callback + def dispatcher_message(self, identifier: str, data: Any = None) -> None: + """Match cloud notification to dispatcher.""" + if identifier.startswith("remote_"): + async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + + async def async_alexa_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud alexa message to client.""" + return await alexa_sh.async_handle_message( + self._hass, self.alexa_config, payload, + enabled=self._prefs.alexa_enabled + ) + + async def async_google_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud google message to client.""" + if not self._prefs.google_enabled: + return ga.turned_off_response(payload) + + answer = await ga.async_handle_message( + self._hass, self.google_config, self.prefs.cloud_user, payload + ) + + # Fix AgentUserId + cloud = self._hass.data[DOMAIN] + answer['payload']['agentUserId'] = cloud.claims['cognito:username'] + + return answer + + async def async_webhook_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud webhook message to client.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in self._prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await self._hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = utils.aiohttp_serialize_response(response) + body = response_dict.get('body') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } + + async def async_cloudhooks_update( + self, data: Dict[str, Dict[str, str]]) -> None: + """Update local list of cloudhooks.""" + await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index c62768cc514..00000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Cloud APIs.""" -from functools import wraps -import logging - -from . import auth_api - -_LOGGER = logging.getLogger(__name__) - - -def _check_token(func): - """Decorate a function to verify valid token.""" - @wraps(func) - async def check_token(cloud, *args): - """Validate token, then call func.""" - await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) - return await func(cloud, *args) - - return check_token - - -def _log_response(func): - """Decorate a function to log bad responses.""" - @wraps(func) - async def log_response(*args): - """Log response if it's bad.""" - resp = await func(*args) - meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning - meth('Fetched %s (%s)', resp.url, resp.status) - return resp - - return log_response - - -@_check_token -@_log_response -async def async_create_cloudhook(cloud): - """Create a cloudhook.""" - websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() - return await websession.post( - cloud.cloudhook_create_url, headers={ - 'authorization': cloud.id_token - }) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py deleted file mode 100644 index 3c638d29166..00000000000 --- a/homeassistant/components/cloud/cloudhooks.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Manage cloud cloudhooks.""" -import async_timeout - -from . import cloud_api - - -class Cloudhooks: - """Class to help manage cloudhooks.""" - - def __init__(self, cloud): - """Initialize cloudhooks.""" - self.cloud = cloud - self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) - - async def async_publish_cloudhooks(self): - """Inform the Relayer of the cloudhooks that we support.""" - cloudhooks = self.cloud.prefs.cloudhooks - await self.cloud.iot.async_send_message('webhook-register', { - 'cloudhook_ids': [info['cloudhook_id'] for info - in cloudhooks.values()] - }, expect_answer=False) - - async def async_create(self, webhook_id): - """Create a cloud webhook.""" - cloudhooks = self.cloud.prefs.cloudhooks - - if webhook_id in cloudhooks: - raise ValueError('Hook is already enabled for the cloud.') - - if not self.cloud.iot.connected: - raise ValueError("Cloud is not connected") - - # Create cloud hook - with async_timeout.timeout(10): - resp = await cloud_api.async_create_cloudhook(self.cloud) - - data = await resp.json() - cloudhook_id = data['cloudhook_id'] - cloudhook_url = data['url'] - - # Store hook - cloudhooks = dict(cloudhooks) - hook = cloudhooks[webhook_id] = { - 'webhook_id': webhook_id, - 'cloudhook_id': cloudhook_id, - 'cloudhook_url': cloudhook_url - } - await self.cloud.prefs.async_update(cloudhooks=cloudhooks) - - await self.async_publish_cloudhooks() - - return hook - - async def async_delete(self, webhook_id): - """Delete a cloud webhook.""" - cloudhooks = self.cloud.prefs.cloudhooks - - if webhook_id not in cloudhooks: - raise ValueError('Hook is not enabled for the cloud.') - - # Remove hook - cloudhooks = dict(cloudhooks) - cloudhooks.pop(webhook_id) - await self.cloud.prefs.async_update(cloudhooks=cloudhooks) - - await self.async_publish_cloudhooks() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index a5019efaa8e..1286832c0c7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,34 +1,33 @@ """Constants for the cloud component.""" DOMAIN = 'cloud' -CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_ENABLE_REMOTE = 'remote_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' PREF_CLOUDHOOKS = 'cloudhooks' +PREF_CLOUD_USER = 'cloud_user' -SERVERS = { - 'production': { - 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', - 'user_pool_id': 'us-east-1_87ll5WOP8', - 'region': 'us-east-1', - 'relayer': 'wss://cloud.hass.io:8000/websocket', - 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' - 'amazonaws.com/prod/smart_home_sync'), - 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' - 'subscription_info'), - 'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' - } -} +CONF_ALEXA = 'alexa' +CONF_ALIASES = 'aliases' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_FILTER = 'filter' +CONF_GOOGLE_ACTIONS = 'google_actions' +CONF_RELAYER = 'relayer' +CONF_USER_POOL_ID = 'user_pool_id' +CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' +CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' +CONF_REMOTE_API_URL = 'remote_api_url' +CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server' -MESSAGE_EXPIRATION = """ -It looks like your Home Assistant Cloud subscription has expired. Please check -your [account page](/config/cloud/account) to continue using the service. -""" +MODE_DEV = "development" +MODE_PROD = "production" -MESSAGE_AUTH_FAIL = """ -You have been logged out of Home Assistant Cloud because we have been unable -to verify your credentials. Please [log in](/config/cloud) again to continue -using the service. -""" +DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' + + +class InvalidTrustedNetworks(Exception): + """Raised when invalid trusted networks config.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a2825eb6d7b..212bdfb4bf8 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ import asyncio from functools import wraps import logging +import attr import aiohttp import async_timeout import voluptuous as vol @@ -15,11 +16,9 @@ from homeassistant.components import websocket_api from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import smart_home as google_sh -from . import auth_api from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK) -from .iot import STATE_DISCONNECTED, STATE_CONNECTED + PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks) _LOGGER = logging.getLogger(__name__) @@ -59,6 +58,13 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +_CLOUD_ERRORS = { + InvalidTrustedNetworks: + (500, 'Remote UI not compatible with 127.0.0.1/::1' + ' as a trusted network.') +} + + async def async_setup(hass): """Initialize the HTTP API.""" hass.components.websocket_api.async_register_command( @@ -81,6 +87,10 @@ async def async_setup(hass): WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE ) + hass.components.websocket_api.async_register_command( + websocket_remote_connect) + hass.components.websocket_api.async_register_command( + websocket_remote_disconnect) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -88,14 +98,22 @@ async def async_setup(hass): hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + from hass_nabucasa import auth -_CLOUD_ERRORS = { - auth_api.UserNotFound: (400, "User does not exist."), - auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), - auth_api.Unauthenticated: (401, 'Authentication failed.'), - auth_api.PasswordChangeRequired: (400, 'Password change required.'), - asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') -} + _CLOUD_ERRORS.update({ + auth.UserNotFound: + (400, "User does not exist."), + auth.UserNotConfirmed: + (400, 'Email not confirmed.'), + auth.Unauthenticated: + (401, 'Authentication failed.'), + auth.PasswordChangeRequired: + (400, 'Password change required.'), + asyncio.TimeoutError: + (502, 'Unable to reach the Home Assistant cloud.'), + aiohttp.ClientError: + (500, 'Error making internal request'), + }) def _handle_cloud_errors(handler): @@ -108,12 +126,7 @@ def _handle_cloud_errors(handler): return result except Exception as err: # pylint: disable=broad-except - err_info = _CLOUD_ERRORS.get(err.__class__) - if err_info is None: - _LOGGER.exception( - "Unexpected error processing request for %s", request.path) - err_info = (502, 'Unexpected error: {}'.format(err)) - status, msg = err_info + status, msg = _process_cloud_exception(err, request.path) return view.json_message( msg, status_code=status, message_code=err.__class__.__name__.lower()) @@ -121,6 +134,31 @@ def _handle_cloud_errors(handler): return error_handler +def _ws_handle_cloud_errors(handler): + """Websocket decorator to handle auth errors.""" + @wraps(handler) + async def error_handler(hass, connection, msg): + """Handle exceptions that raise from the wrapped handler.""" + try: + return await handler(hass, connection, msg) + + except Exception as err: # pylint: disable=broad-except + err_status, err_msg = _process_cloud_exception(err, msg['type']) + connection.send_error(msg['id'], err_status, err_msg) + + return error_handler + + +def _process_cloud_exception(exc, where): + """Process a cloud exception.""" + err_info = _CLOUD_ERRORS.get(exc.__class__) + if err_info is None: + _LOGGER.exception( + "Unexpected error processing request for %s", where) + err_info = (502, 'Unexpected error: {}'.format(exc)) + return err_info + + class GoogleActionsSyncView(HomeAssistantView): """Trigger a Google Actions Smart Home Sync.""" @@ -135,7 +173,7 @@ class GoogleActionsSyncView(HomeAssistantView): websession = hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(auth_api.check_token, cloud) + await hass.async_add_job(cloud.auth.check_token) with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): req = await websession.post( @@ -163,7 +201,7 @@ class CloudLoginView(HomeAssistantView): cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(auth_api.login, cloud, data['email'], + await hass.async_add_job(cloud.auth.login, data['email'], data['password']) hass.async_add_job(cloud.iot.connect) @@ -206,7 +244,7 @@ class CloudRegisterView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.register, cloud, data['email'], data['password']) + cloud.auth.register, data['email'], data['password']) return self.json_message('ok') @@ -228,7 +266,7 @@ class CloudResendConfirmView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.resend_email_confirm, cloud, data['email']) + cloud.auth.resend_email_confirm, data['email']) return self.json_message('ok') @@ -250,7 +288,7 @@ class CloudForgotPasswordView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.forgot_password, cloud, data['email']) + cloud.auth.forgot_password, data['email']) return self.json_message('ok') @@ -283,30 +321,11 @@ def _require_cloud_login(handler): return with_cloud_auth -def _handle_aiohttp_errors(handler): - """Websocket decorator that handlers aiohttp errors. - - Can only wrap async handlers. - """ - @wraps(handler) - async def with_error_handling(hass, connection, msg): - """Handle aiohttp errors.""" - try: - await handler(hass, connection, msg) - except asyncio.TimeoutError: - connection.send_message(websocket_api.error_message( - msg['id'], 'timeout', 'Command timed out.')) - except aiohttp.ClientError: - connection.send_message(websocket_api.error_message( - msg['id'], 'unknown', 'Error making request.')) - - return with_error_handling - - @_require_cloud_login @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" + from hass_nabucasa.const import STATE_DISCONNECTED cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): @@ -320,11 +339,10 @@ async def websocket_subscription(hass, connection, msg): # Check if a user is subscribed but local info is outdated # In that case, let's refresh and reconnect - if data.get('provider') and cloud.iot.state != STATE_CONNECTED: + if data.get('provider') and not cloud.is_connected: _LOGGER.debug( "Found disconnected account with valid subscriotion, connecting") - await hass.async_add_executor_job( - auth_api.renew_access_token, cloud) + await hass.async_add_executor_job(cloud.auth.renew_access_token) # Cancel reconnect in progress if cloud.iot.state != STATE_DISCONNECTED: @@ -344,23 +362,24 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') - await cloud.prefs.async_update(**changes) + await cloud.client.prefs.async_update(**changes) connection.send_message(websocket_api.result_message(msg['id'])) @_require_cloud_login @websocket_api.async_response -@_handle_aiohttp_errors +@_ws_handle_cloud_errors async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + hook = await cloud.cloudhooks.async_create(msg['webhook_id'], False) connection.send_message(websocket_api.result_message(msg['id'], hook)) @_require_cloud_login @websocket_api.async_response +@_ws_handle_cloud_errors async def websocket_hook_delete(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -370,6 +389,8 @@ async def websocket_hook_delete(hass, connection, msg): def _account_data(cloud): """Generate the auth data JSON response.""" + from hass_nabucasa.const import STATE_DISCONNECTED + if not cloud.is_logged_in: return { 'logged_in': False, @@ -377,14 +398,53 @@ def _account_data(cloud): } claims = cloud.claims + client = cloud.client + remote = cloud.remote + + # Load remote certificate + if remote.certificate: + certificate = attr.asdict(remote.certificate) + else: + certificate = None return { 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, - 'prefs': cloud.prefs.as_dict(), - 'google_entities': cloud.google_actions_user_conf['filter'].config, + 'prefs': client.prefs.as_dict(), + 'google_entities': client.google_user_config['filter'].config, 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), - 'alexa_entities': cloud.alexa_config.should_expose.config, + 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + 'remote_domain': remote.instance_domain, + 'remote_connected': remote.is_connected, + 'remote_certificate': certificate, } + + +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/remote/connect' +}) +async def websocket_remote_connect(hass, connection, msg): + """Handle request for connect remote.""" + cloud = hass.data[DOMAIN] + await cloud.client.prefs.async_update(remote_enabled=True) + await cloud.remote.connect() + connection.send_result(msg['id'], _account_data(cloud)) + + +@_require_cloud_login +@websocket_api.async_response +@_ws_handle_cloud_errors +@websocket_api.websocket_command({ + 'type': 'cloud/remote/disconnect' +}) +async def websocket_remote_disconnect(hass, connection, msg): + """Handle request for disconnect remote.""" + cloud = hass.data[DOMAIN] + await cloud.client.prefs.async_update(remote_enabled=False) + await cloud.remote.disconnect() + connection.send_result(msg['id'], _account_data(cloud)) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py deleted file mode 100644 index 055c4dbaa64..00000000000 --- a/homeassistant/components/cloud/iot.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Module to handle messages from Home Assistant cloud.""" -import asyncio -import logging -import pprint -import random -import uuid - -from aiohttp import hdrs, client_exceptions, WSMsgType - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.components.alexa import smart_home as alexa -from homeassistant.components.google_assistant import smart_home as ga -from homeassistant.core import callback -from homeassistant.util.decorator import Registry -from homeassistant.util.aiohttp import MockRequest -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import auth_api -from . import utils -from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL - -HANDLERS = Registry() -_LOGGER = logging.getLogger(__name__) - -STATE_CONNECTING = 'connecting' -STATE_CONNECTED = 'connected' -STATE_DISCONNECTED = 'disconnected' - - -class UnknownHandler(Exception): - """Exception raised when trying to handle unknown handler.""" - - -class NotConnected(Exception): - """Exception raised when trying to handle unknown handler.""" - - -class ErrorMessage(Exception): - """Exception raised when there was error handling message in the cloud.""" - - def __init__(self, error): - """Initialize Error Message.""" - super().__init__(self, "Error in Cloud") - self.error = error - - -class CloudIoT: - """Class to manage the IoT connection.""" - - def __init__(self, cloud): - """Initialize the CloudIoT class.""" - self.cloud = cloud - # The WebSocket client - self.client = None - # Scheduled sleep task till next connection retry - self.retry_task = None - # Boolean to indicate if we wanted the connection to close - self.close_requested = False - # The current number of attempts to connect, impacts wait time - self.tries = 0 - # Current state of the connection - self.state = STATE_DISCONNECTED - # Local code waiting for a response - self._response_handler = {} - self._on_connect = [] - self._on_disconnect = [] - - @callback - def register_on_connect(self, on_connect_cb): - """Register an async on_connect callback.""" - self._on_connect.append(on_connect_cb) - - @callback - def register_on_disconnect(self, on_disconnect_cb): - """Register an async on_disconnect callback.""" - self._on_disconnect.append(on_disconnect_cb) - - @property - def connected(self): - """Return if we're currently connected.""" - return self.state == STATE_CONNECTED - - @asyncio.coroutine - def connect(self): - """Connect to the IoT broker.""" - if self.state != STATE_DISCONNECTED: - raise RuntimeError('Connect called while not disconnected') - - hass = self.cloud.hass - self.close_requested = False - self.state = STATE_CONNECTING - self.tries = 0 - - @asyncio.coroutine - def _handle_hass_stop(event): - """Handle Home Assistant shutting down.""" - nonlocal remove_hass_stop_listener - remove_hass_stop_listener = None - yield from self.disconnect() - - remove_hass_stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) - - while True: - try: - yield from self._handle_connection() - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we can always reconnect - _LOGGER.exception("Unexpected error") - - if self.state == STATE_CONNECTED and self._on_disconnect: - try: - yield from asyncio.wait([ - cb() for cb in self._on_disconnect - ]) - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we don't break the flow - _LOGGER.exception( - "Unexpected error in on_disconnect callbacks") - - if self.close_requested: - break - - self.state = STATE_CONNECTING - self.tries += 1 - - try: - # Sleep 2^tries + 0…tries*3 seconds between retries - self.retry_task = hass.async_create_task( - asyncio.sleep(2**min(9, self.tries) + - random.randint(0, self.tries * 3), - loop=hass.loop)) - yield from self.retry_task - self.retry_task = None - except asyncio.CancelledError: - # Happens if disconnect called - break - - self.state = STATE_DISCONNECTED - if remove_hass_stop_listener is not None: - remove_hass_stop_listener() - - async def async_send_message(self, handler, payload, - expect_answer=True): - """Send a message.""" - if self.state != STATE_CONNECTED: - raise NotConnected - - msgid = uuid.uuid4().hex - - if expect_answer: - fut = self._response_handler[msgid] = asyncio.Future() - - message = { - 'msgid': msgid, - 'handler': handler, - 'payload': payload, - } - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Publishing message:\n%s\n", - pprint.pformat(message)) - await self.client.send_json(message) - - if expect_answer: - return await fut - - @asyncio.coroutine - def _handle_connection(self): - """Connect to the IoT broker.""" - hass = self.cloud.hass - - try: - yield from hass.async_add_job(auth_api.check_token, self.cloud) - except auth_api.Unauthenticated as err: - _LOGGER.error('Unable to refresh token: %s', err) - - hass.components.persistent_notification.async_create( - MESSAGE_AUTH_FAIL, 'Home Assistant Cloud', - 'cloud_subscription_expired') - - # Don't await it because it will cancel this task - hass.async_create_task(self.cloud.logout()) - return - except auth_api.CloudError as err: - _LOGGER.warning("Unable to refresh token: %s", err) - return - - if self.cloud.subscription_expired: - hass.components.persistent_notification.async_create( - MESSAGE_EXPIRATION, 'Home Assistant Cloud', - 'cloud_subscription_expired') - self.close_requested = True - return - - session = async_get_clientsession(self.cloud.hass) - client = None - disconnect_warn = None - - try: - self.client = client = yield from session.ws_connect( - self.cloud.relayer, heartbeat=55, headers={ - hdrs.AUTHORIZATION: - 'Bearer {}'.format(self.cloud.id_token) - }) - self.tries = 0 - - _LOGGER.info("Connected") - self.state = STATE_CONNECTED - - if self._on_connect: - try: - yield from asyncio.wait([cb() for cb in self._on_connect]) - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we don't break the flow - _LOGGER.exception( - "Unexpected error in on_connect callbacks") - - while not client.closed: - msg = yield from client.receive() - - if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): - break - - elif msg.type == WSMsgType.ERROR: - disconnect_warn = 'Connection error' - break - - elif msg.type != WSMsgType.TEXT: - disconnect_warn = 'Received non-Text message: {}'.format( - msg.type) - break - - try: - msg = msg.json() - except ValueError: - disconnect_warn = 'Received invalid JSON.' - break - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Received message:\n%s\n", - pprint.pformat(msg)) - - response_handler = self._response_handler.pop(msg['msgid'], - None) - - if response_handler is not None: - if 'payload' in msg: - response_handler.set_result(msg["payload"]) - else: - response_handler.set_exception( - ErrorMessage(msg['error'])) - continue - - response = { - 'msgid': msg['msgid'], - } - try: - result = yield from async_handle_message( - hass, self.cloud, msg['handler'], msg['payload']) - - # No response from handler - if result is None: - continue - - response['payload'] = result - - except UnknownHandler: - response['error'] = 'unknown-handler' - - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error handling message") - response['error'] = 'exception' - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Publishing message:\n%s\n", - pprint.pformat(response)) - yield from client.send_json(response) - - except client_exceptions.WSServerHandshakeError as err: - if err.status == 401: - disconnect_warn = 'Invalid auth.' - self.close_requested = True - # Should we notify user? - else: - _LOGGER.warning("Unable to connect: %s", err) - - except client_exceptions.ClientError as err: - _LOGGER.warning("Unable to connect: %s", err) - - finally: - if disconnect_warn is None: - _LOGGER.info("Connection closed") - else: - _LOGGER.warning("Connection closed: %s", disconnect_warn) - - @asyncio.coroutine - def disconnect(self): - """Disconnect the client.""" - self.close_requested = True - - if self.client is not None: - yield from self.client.close() - elif self.retry_task is not None: - self.retry_task.cancel() - - -@asyncio.coroutine -def async_handle_message(hass, cloud, handler_name, payload): - """Handle incoming IoT message.""" - handler = HANDLERS.get(handler_name) - - if handler is None: - raise UnknownHandler() - - return (yield from handler(hass, cloud, payload)) - - -@HANDLERS.register('alexa') -@asyncio.coroutine -def async_handle_alexa(hass, cloud, payload): - """Handle an incoming IoT message for Alexa.""" - result = yield from alexa.async_handle_message( - hass, cloud.alexa_config, payload, - enabled=cloud.prefs.alexa_enabled) - return result - - -@HANDLERS.register('google_actions') -@asyncio.coroutine -def async_handle_google_actions(hass, cloud, payload): - """Handle an incoming IoT message for Google Actions.""" - if not cloud.prefs.google_enabled: - return ga.turned_off_response(payload) - - result = yield from ga.async_handle_message( - hass, cloud.gactions_config, payload) - return result - - -@HANDLERS.register('cloud') -async def async_handle_cloud(hass, cloud, payload): - """Handle an incoming IoT message for cloud component.""" - action = payload['action'] - - if action == 'logout': - # Log out of Home Assistant Cloud - await cloud.logout() - _LOGGER.error("You have been logged out from Home Assistant cloud: %s", - payload['reason']) - else: - _LOGGER.warning("Received unknown cloud action: %s", action) - - -@HANDLERS.register('webhook') -async def async_handle_webhook(hass, cloud, payload): - """Handle an incoming IoT message for cloud webhooks.""" - cloudhook_id = payload['cloudhook_id'] - - found = None - for cloudhook in cloud.prefs.cloudhooks.values(): - if cloudhook['cloudhook_id'] == cloudhook_id: - found = cloudhook - break - - if found is None: - return { - 'status': 200 - } - - request = MockRequest( - content=payload['body'].encode('utf-8'), - headers=payload['headers'], - method=payload['method'], - query_string=payload['query'], - ) - - response = await hass.components.webhook.async_handle_webhook( - found['webhook_id'], request) - - response_dict = utils.aiohttp_serialize_response(response) - body = response_dict.get('body') - - return { - 'body': body, - 'status': response_dict['status'], - 'headers': { - 'Content-Type': response.content_type - } - } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 32362df2fa9..b0244f6b1fb 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,10 @@ """Preference management for cloud.""" +from ipaddress import ip_address + from .const import ( - DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER, + InvalidTrustedNetworks) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -13,6 +16,7 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" + self._hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None @@ -24,31 +28,52 @@ class CloudPreferences: prefs = { PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, + PREF_ENABLE_REMOTE: False, PREF_GOOGLE_ALLOW_UNLOCK: False, - PREF_CLOUDHOOKS: {} + PREF_CLOUDHOOKS: {}, + PREF_CLOUD_USER: None, } self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, - cloudhooks=_UNDEF): + alexa_enabled=_UNDEF, remote_enabled=_UNDEF, + google_allow_unlock=_UNDEF, cloudhooks=_UNDEF, + cloud_user=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_ENABLE_REMOTE, remote_enabled), (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), (PREF_CLOUDHOOKS, cloudhooks), + (PREF_CLOUD_USER, cloud_user), ): if value is not _UNDEF: self._prefs[key] = value + if remote_enabled is True and self._has_local_trusted_network: + raise InvalidTrustedNetworks + await self._store.async_save(self._prefs) def as_dict(self): """Return dictionary version.""" return self._prefs + @property + def remote_enabled(self): + """Return if remote is enabled on start.""" + enabled = self._prefs.get(PREF_ENABLE_REMOTE, False) + + if not enabled: + return False + + if self._has_local_trusted_network: + return False + + return True + @property def alexa_enabled(self): """Return if Alexa is enabled.""" @@ -68,3 +93,24 @@ class CloudPreferences: def cloudhooks(self): """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) + + @property + def cloud_user(self) -> str: + """Return ID from Home Assistant Cloud system user.""" + return self._prefs.get(PREF_CLOUD_USER) + + @property + def _has_local_trusted_network(self) -> bool: + """Return if we allow localhost to bypass auth.""" + local4 = ip_address('127.0.0.1') + local6 = ip_address('::1') + + for prv in self._hass.auth.auth_providers: + if prv.type != 'trusted_networks': + continue + + for network in prv.trusted_networks: + if local4 in network or local6 in network: + return True + + return False diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml new file mode 100644 index 00000000000..9ef814e0087 --- /dev/null +++ b/homeassistant/components/cloud/services.yaml @@ -0,0 +1,7 @@ +# Describes the format for available light services + +remote_connect: + description: Make instance UI available outside over NabuCasa cloud. + +remote_disconnect: + description: Disconnect UI from NabuCasa cloud. diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 65a4d50be84..efabd03b586 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,13 +1,13 @@ """Component to configure Home Assistant via an API.""" import asyncio +import importlib import os import voluptuous as vol from homeassistant.core import callback from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID -from homeassistant.setup import ( - async_prepare_setup_platform, ATTR_COMPONENT) +from homeassistant.setup import ATTR_COMPONENT from homeassistant.components.http import HomeAssistantView from homeassistant.util.yaml import load_yaml, dump @@ -24,7 +24,6 @@ SECTIONS = ( 'device_registry', 'entity_registry', 'group', - 'hassbian', 'script', ) ON_DEMAND = ('zwave',) @@ -37,8 +36,7 @@ async def async_setup(hass, config): async def setup_panel(panel_name): """Set up a panel.""" - panel = await async_prepare_setup_platform( - hass, config, DOMAIN, panel_name) + panel = importlib.import_module('.{}'.format(panel_name), __name__) if not panel: return diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 7f1bb938228..06fc3eae34d 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -8,8 +8,6 @@ from homeassistant.core import callback from homeassistant.helpers.area_registry import async_get_registry -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/area_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 625dbefbbb3..e6451e09a98 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -36,6 +36,7 @@ async def async_setup(hass): WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE ) + hass.components.websocket_api.async_register_command(websocket_update) return True @@ -84,6 +85,40 @@ async def websocket_create(hass, connection, msg): })) +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'config/auth/update', + vol.Required('user_id'): str, + vol.Optional('name'): str, + vol.Optional('group_ids'): [str] +}) +async def websocket_update(hass, connection, msg): + """Update a user.""" + user = await hass.auth.async_get_user(msg.pop('user_id')) + + if not user: + connection.send_message(websocket_api.error_message( + msg['id'], websocket_api.const.ERR_NOT_FOUND, 'User not found')) + return + + if user.system_generated: + connection.send_message(websocket_api.error_message( + msg['id'], 'cannot_modify_system_generated', + 'Unable to update system generated users.')) + return + + msg.pop('type') + msg_id = msg.pop('id') + + await hass.auth.async_update_user(user, **msg) + + connection.send_message( + websocket_api.result_message(msg_id, { + 'user': _user_info(user), + })) + + def _user_info(user): """Format a user.""" return { diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 5455277aa78..f6fc4bc8cef 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -122,7 +122,6 @@ async def websocket_delete(hass, connection, msg): websocket_api.result_message(msg['id'])) -@websocket_api.require_admin @websocket_api.async_response async def websocket_change_password(hass, connection, msg): """Change user password.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 65f65cbcec5..8865ff39cea 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -118,6 +118,16 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): # pylint: disable=no-value-for-parameter return await super().post(request) + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data['result'] = data['result'].entry_id + data.pop('data') + return data + class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" @@ -143,6 +153,16 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data['result'] = data['result'].entry_id + data.pop('data') + return data + class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" @@ -175,7 +195,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): return await super().post(request) -class OptionManagerFlowResourceView(ConfigManagerFlowResourceView): +class OptionManagerFlowResourceView(FlowManagerResourceView): """View to interact with the option flow manager.""" url = '/api/config/config_entries/options/flow/{flow_id}' diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 9554f6aeee6..d9e55bbe67e 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -7,8 +7,6 @@ from homeassistant.components.websocket_api.decorators import ( from homeassistant.core import callback from homeassistant.helpers.device_registry import async_get_registry -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/device_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 39dd622540d..341b05f966b 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -9,8 +9,6 @@ from homeassistant.components.websocket_api.decorators import ( async_response, require_admin) from homeassistant.helpers import config_validation as cv -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/entity_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, diff --git a/homeassistant/components/config/hassbian.py b/homeassistant/components/config/hassbian.py deleted file mode 100644 index c475dc317f7..00000000000 --- a/homeassistant/components/config/hassbian.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Component to interact with Hassbian tools.""" -import json -import os - -from homeassistant.components.http import HomeAssistantView - - -_TEST_OUTPUT = """ -{ - "suites":{ - "libcec":{ - "state":"Uninstalled", - "description":"Installs the libcec package for controlling CEC devices from this Pi" - }, - "mosquitto":{ - "state":"failed", - "description":"Installs the Mosquitto package for setting up a local MQTT server" - }, - "openzwave":{ - "state":"Uninstalled", - "description":"Installs the Open Z-wave package for setting up your zwave network" - }, - "samba":{ - "state":"installing", - "description":"Installs the samba package for sharing the hassbian configuration files over the Pi's network." - } - } -} -""" # noqa - - -async def async_setup(hass): - """Set up the Hassbian config.""" - # Test if is Hassbian - test_mode = 'FORCE_HASSBIAN' in os.environ - is_hassbian = test_mode - - if not is_hassbian: - return False - - hass.http.register_view(HassbianSuitesView(test_mode)) - hass.http.register_view(HassbianSuiteInstallView(test_mode)) - - return True - - -async def hassbian_status(hass, test_mode=False): - """Query for the Hassbian status.""" - # Fetch real output when not in test mode - if test_mode: - return json.loads(_TEST_OUTPUT) - - raise Exception('Real mode not implemented yet.') - - -class HassbianSuitesView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites' - name = 'api:config:hassbian:suites' - - def __init__(self, test_mode): - """Initialize suites view.""" - self._test_mode = test_mode - - async def get(self, request): - """Request suite status.""" - inp = await hassbian_status(request.app['hass'], self._test_mode) - - return self.json(inp['suites']) - - -class HassbianSuiteInstallView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites/{suite}/install' - name = 'api:config:hassbian:suite' - - def __init__(self, test_mode): - """Initialize suite view.""" - self._test_mode = test_mode - - async def post(self, request, suite): - """Request suite status.""" - # do real install if not in test mode - return self.json({"status": "ok"}) diff --git a/homeassistant/components/cppm_tracker/__init__.py b/homeassistant/components/cppm_tracker/__init__.py new file mode 100644 index 00000000000..cb6aa87881d --- /dev/null +++ b/homeassistant/components/cppm_tracker/__init__.py @@ -0,0 +1 @@ +"""Add support for ClearPass Policy Manager.""" diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py new file mode 100755 index 00000000000..2ca0ebf62e5 --- /dev/null +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -0,0 +1,85 @@ +""" +Support for ClearPass Policy Manager. + +Allows tracking devices with CPPM. +""" +import logging +from datetime import timedelta + +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, DeviceScanner, DOMAIN +) +from homeassistant.const import ( + CONF_HOST, CONF_API_KEY +) + +REQUIREMENTS = ['clearpasspy==1.0.2'] + +SCAN_INTERVAL = timedelta(seconds=120) + +CLIENT_ID = 'client_id' + +GRANT_TYPE = 'client_credentials' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CLIENT_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def get_scanner(hass, config): + """Initialize Scanner.""" + from clearpasspy import ClearPass + data = { + 'server': config[DOMAIN][CONF_HOST], + 'grant_type': GRANT_TYPE, + 'secret': config[DOMAIN][CONF_API_KEY], + 'client': config[DOMAIN][CLIENT_ID] + } + cppm = ClearPass(data) + if cppm.access_token is None: + return None + _LOGGER.debug("Successfully received Access Token") + return CPPMDeviceScanner(cppm) + + +class CPPMDeviceScanner(DeviceScanner): + """Initialize class.""" + + def __init__(self, cppm): + """Initialize class.""" + self._cppm = cppm + self.results = None + + def scan_devices(self): + """Initialize scanner.""" + self.get_cppm_data() + return [device['mac'] for device in self.results] + + def get_device_name(self, device): + """Retrieve device name.""" + name = next(( + result['name'] for result in self.results + if result['mac'] == device), None) + return name + + def get_cppm_data(self): + """Retrieve data from Aruba Clearpass and return parsed result.""" + endpoints = self._cppm.get_endpoints(100)['_embedded']['items'] + devices = [] + for item in endpoints: + if self._cppm.online_status(item['mac_address']): + device = { + 'mac': item['mac_address'], + 'name': item['mac_address'] + } + devices.append(device) + else: + continue + _LOGGER.debug("Devices: %s", devices) + self.results = devices diff --git a/homeassistant/components/daikin/.translations/fr.json b/homeassistant/components/daikin/.translations/fr.json new file mode 100644 index 00000000000..cfd4b7442d6 --- /dev/null +++ b/homeassistant/components/daikin/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "device_fail": "Erreur inattendue lors de la cr\u00e9ation du p\u00e9riph\u00e9rique.", + "device_timeout": "D\u00e9lai de connexion au p\u00e9riph\u00e9rique expir\u00e9." + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Entrez l'adresse IP de votre Daikin AC.", + "title": "Configurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/th.json b/homeassistant/components/daikin/.translations/th.json new file mode 100644 index 00000000000..8f0fdda3711 --- /dev/null +++ b/homeassistant/components/daikin/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant/components/daikin/.translations/zh-Hans.json index 1330e3a932d..5123dc2366b 100644 --- a/homeassistant/components/daikin/.translations/zh-Hans.json +++ b/homeassistant/components/daikin/.translations/zh-Hans.json @@ -10,7 +10,7 @@ "data": { "host": "\u4e3b\u673a" }, - "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684IP\u5730\u5740\u3002", + "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002", "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" } }, diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index dce2c7a6704..39975eaa39e 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -17,7 +17,7 @@ "title": "Definiere das deCONZ-Gateway" }, "link": { - "description": "Entsperre dein deCONZ-Gateway, um dich bei Home Assistant zu registrieren. \n\n 1. Gehe zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", + "description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" }, "options": { diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 9084d22f4a3..d4b65f16552 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -12,12 +12,12 @@ "init": { "data": { "host": "Host", - "port": "Poort (standaard: '80')" + "port": "Poort" }, "title": "Definieer deCONZ gateway" }, "link": { - "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", + "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" }, "options": { diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index badc403c7c8..888a4d51c95 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -6,7 +6,6 @@ DEPENDENCIES = ( 'cloud', 'config', 'conversation', - 'discovery', 'frontend', 'history', 'logbook', @@ -17,6 +16,7 @@ DEPENDENCIES = ( 'sun', 'system_health', 'updater', + 'zeroconf', ) diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 3a820d189f4..0a1b327dca9 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -41,17 +41,17 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): for dev_id, topic in devices.items(): @callback - def async_message_received(topic, payload, qos, dev_id=dev_id): + def async_message_received(msg, dev_id=dev_id): """Handle received MQTT message.""" try: - data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) + data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(msg.payload)) except vol.MultipleInvalid: _LOGGER.error("Skipping update for following data " "because of missing or malformatted data: %s", - payload) + msg.payload) return except ValueError: - _LOGGER.error("Error parsing JSON payload: %s", payload) + _LOGGER.error("Error parsing JSON payload: %s", msg.payload) return kwargs = _parse_see_args(dev_id, data) diff --git a/homeassistant/components/device_tracker/quantum_gateway.py b/homeassistant/components/device_tracker/quantum_gateway.py index a06794f9179..90ba3575cfa 100644 --- a/homeassistant/components/device_tracker/quantum_gateway.py +++ b/homeassistant/components/device_tracker/quantum_gateway.py @@ -11,10 +11,10 @@ import voluptuous as vol from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import (CONF_HOST, CONF_PASSWORD) +from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_SSL) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['quantum-gateway==0.0.3'] +REQUIREMENTS = ['quantum-gateway==0.0.5'] _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ DEFAULT_HOST = 'myfiosgateway.com' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_SSL, default=True): cv.boolean, vol.Required(CONF_PASSWORD): cv.string }) @@ -42,10 +43,12 @@ class QuantumGatewayDeviceScanner(DeviceScanner): self.host = config[CONF_HOST] self.password = config[CONF_PASSWORD] + self.use_https = config[CONF_SSL] _LOGGER.debug('Initializing') try: - self.quantum = QuantumGatewayScanner(self.host, self.password) + self.quantum = QuantumGatewayScanner(self.host, self.password, + self.use_https) self.success_init = self.quantum.success_init except RequestException: self.success_init = False diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 81d8a6867c6..6da520280e2 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pytile==2.0.5'] +REQUIREMENTS = ['pytile==2.0.6'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEVICE_TYPES = ['PHONE', 'TILE'] diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 0c0c908d1e0..2dc5f7a4df3 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -43,6 +43,8 @@ AVAILABLE_ATTRS = [ 'uptime', 'user_id', 'usergroup_id', 'vlan' ] +TIMESTAMP_ATTRS = ['first_seen', 'last_seen', 'latest_assoc_time'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_SITE_ID, default='default'): cv.string, @@ -149,7 +151,12 @@ class UnifiScanner(DeviceScanner): attributes = {} for variable in self._monitored_conditions: if variable in client: - attributes[variable] = client[variable] + if variable in TIMESTAMP_ATTRS: + attributes[variable] = dt_util.utc_from_timestamp( + float(client[variable]) + ) + else: + attributes[variable] = client[variable] _LOGGER.debug("Device mac %s attributes %s", device, attributes) return attributes diff --git a/homeassistant/components/device_tracker/xfinity.py b/homeassistant/components/device_tracker/xfinity.py new file mode 100644 index 00000000000..04702355de7 --- /dev/null +++ b/homeassistant/components/device_tracker/xfinity.py @@ -0,0 +1,58 @@ +"""Support for device tracking via Xfinity Gateways.""" +import logging + +from requests.exceptions import RequestException +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST + +REQUIREMENTS = ['xfinity-gateway==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = '10.0.0.1' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string +}) + + +def get_scanner(hass, config): + """Validate the configuration and return an Xfinity Gateway scanner.""" + from xfinity_gateway import XfinityGateway + + gateway = XfinityGateway(config[DOMAIN][CONF_HOST]) + scanner = None + try: + gateway.scan_devices() + scanner = XfinityDeviceScanner(gateway) + except (RequestException, ValueError): + _LOGGER.error("Error communicating with Xfinity Gateway. " + "Check host: %s", gateway.host) + + return scanner + + +class XfinityDeviceScanner(DeviceScanner): + """This class queries an Xfinity Gateway.""" + + def __init__(self, gateway): + """Initialize the scanner.""" + self.gateway = gateway + + def scan_devices(self): + """Scan for new devices and return a list of found MACs.""" + connected_devices = [] + try: + connected_devices = self.gateway.scan_devices() + except (RequestException, ValueError): + _LOGGER.error("Unable to scan devices. " + "Check connection to gateway") + return connected_devices + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + return self.gateway.get_device_name(device) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 85e3164d08b..d4816213f50 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -9,7 +9,6 @@ loaded before the EVENT_PLATFORM_DISCOVERED is fired. import json from datetime import timedelta import logging -import os import voluptuous as vol @@ -21,7 +20,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==2.3.0'] +REQUIREMENTS = ['netdisco==2.5.0'] DOMAIN = 'discovery' @@ -31,6 +30,7 @@ SERVICE_AXIS = 'axis' SERVICE_DAIKIN = 'daikin' SERVICE_DECONZ = 'deconz' SERVICE_DLNA_DMR = 'dlna_dmr' +SERVICE_ENIGMA2 = 'enigma2' SERVICE_FREEBOX = 'freebox' SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_HASSIO = 'hassio' @@ -39,6 +39,7 @@ SERVICE_HUE = 'philips_hue' SERVICE_IGD = 'igd' SERVICE_IKEA_TRADFRI = 'ikea_tradfri' SERVICE_KONNECTED = 'konnected' +SERVICE_MOBILE_APP = 'hass_mobile_app' SERVICE_NETGEAR = 'netgear_router' SERVICE_OCTOPRINT = 'octoprint' SERVICE_ROKU = 'roku' @@ -62,12 +63,14 @@ CONFIG_ENTRY_HANDLERS = { } SERVICE_HANDLERS = { + SERVICE_MOBILE_APP: ('mobile_app', None), SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), SERVICE_WEMO: ('wemo', None), SERVICE_HASSIO: ('hassio', None), SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), + SERVICE_ENIGMA2: ('media_player', 'enigma2'), SERVICE_ROKU: ('roku', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), @@ -93,7 +96,7 @@ SERVICE_HANDLERS = { 'kodi': ('media_player', 'kodi'), 'volumio': ('media_player', 'volumio'), 'lg_smart_device': ('media_player', 'lg_soundbar'), - 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), + 'nanoleaf_aurora': ('light', 'nanoleaf'), } OPTIONAL_SERVICE_HANDLERS = { @@ -195,10 +198,6 @@ async def async_setup(hass, config): """Schedule the first discovery when Home Assistant starts up.""" async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) - # Discovery for local services - if 'HASSIO' in os.environ: - hass.async_create_task(new_service_found(SERVICE_HASSIO, {})) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) return True diff --git a/homeassistant/components/ebusd/.translations/nl.json b/homeassistant/components/ebusd/.translations/nl.json new file mode 100644 index 00000000000..db4627790fd --- /dev/null +++ b/homeassistant/components/ebusd/.translations/nl.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Nacht" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/th.json b/homeassistant/components/ebusd/.translations/th.json new file mode 100644 index 00000000000..92a8c7969a8 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/th.json @@ -0,0 +1,5 @@ +{ + "state": { + "night": "\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index c36981c5278..3821bd8ce15 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,4 +1,6 @@ """Constants for ebus component.""" +from homeassistant.const import ENERGY_KILO_WATT_HOUR + DOMAIN = 'ebusd' # SensorTypes: @@ -67,9 +69,9 @@ SENSOR_TYPES = { 'ContinuosHeating': ['ContinuosHeating', '°C', 'mdi:weather-snowy', 0], 'PowerEnergyConsumptionLastMonth': - ['PrEnergySumHcLastMonth', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHcLastMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'PowerEnergyConsumptionThisMonth': - ['PrEnergySumHcThisMonth', 'kWh', 'mdi:flash', 0] + ['PrEnergySumHcThisMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0] }, 'ehp': { 'HWTemperature': @@ -89,12 +91,12 @@ SENSOR_TYPES = { 'Flame': ['Flame', None, 'mdi:toggle-switch', 2], 'PowerEnergyConsumptionHeatingCircuit': - ['PrEnergySumHc1', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'PowerEnergyConsumptionHotWaterCircuit': - ['PrEnergySumHwc1', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHwc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'RoomThermostat': ['DCRoomthermostat', None, 'mdi:toggle-switch', 2], 'HeatingPartLoad': - ['PartloadHcKW', 'kWh', 'mdi:flash', 0] + ['PartloadHcKW', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0] } } diff --git a/homeassistant/components/edp_redy/sensor.py b/homeassistant/components/edp_redy/sensor.py index 926a073832c..389ae77f35b 100644 --- a/homeassistant/components/edp_redy/sensor.py +++ b/homeassistant/components/edp_redy/sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.helpers.entity import Entity +from homeassistant.const import POWER_WATT from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY @@ -29,7 +30,7 @@ async def async_setup_platform( # Create a sensor for global active power devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home", - 'mdi:flash', 'W')) + 'mdi:flash', POWER_WATT)) async_add_entities(devices, True) @@ -89,7 +90,7 @@ class EdpRedyModuleSensor(EdpRedyDevice, Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this sensor.""" - return 'W' + return POWER_WATT async def async_update(self): """Parse the data for this sensor.""" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 95b3c470d9e..4c329cac28f 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -5,13 +5,16 @@ from aiohttp import web from homeassistant import core from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, - SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, STATE_OFF, - HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES, + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, + STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS ) +from homeassistant.components.climate.const import ( + SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE +) from homeassistant.components.media_player.const import ( ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET, ) @@ -26,7 +29,7 @@ from homeassistant.components.cover import ( ) from homeassistant.components import ( - cover, fan, media_player, light, script, scene + climate, cover, fan, media_player, light, script, scene ) from homeassistant.components.http import HomeAssistantView @@ -262,6 +265,18 @@ class HueOneLightChangeView(HomeAssistantView): if brightness is not None: data['variables']['requested_level'] = brightness + # If the requested entity is a climate, set the temperature + elif entity.domain == climate.DOMAIN: + # We don't support turning climate devices on or off, + # only setting the temperature + service = None + + if entity_features & SUPPORT_TARGET_TEMPERATURE: + if brightness is not None: + domain = entity.domain + service = SERVICE_SET_TEMPERATURE + data[ATTR_TEMPERATURE] = brightness + # If the requested entity is a media player, convert to volume elif entity.domain == media_player.DOMAIN: if entity_features & SUPPORT_VOLUME_SET: @@ -318,8 +333,9 @@ class HueOneLightChangeView(HomeAssistantView): core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True)) - hass.async_create_task(hass.services.async_call( - domain, service, data, blocking=True)) + if service is not None: + hass.async_create_task(hass.services.async_call( + domain, service, data, blocking=True)) json_response = \ [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)] @@ -371,7 +387,7 @@ def parse_hue_api_put_light_body(request_json, entity): elif entity.domain in [ script.DOMAIN, media_player.DOMAIN, - fan.DOMAIN, cover.DOMAIN]: + fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]: # Convert 0-255 to 0-100 level = brightness / 255 * 100 brightness = round(level) @@ -397,6 +413,10 @@ def get_entity_state(config, entity): if entity_features & SUPPORT_BRIGHTNESS: pass + elif entity.domain == climate.DOMAIN: + temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + # Convert 0-100 to 0-255 + final_brightness = round(temperature * 255 / 100) elif entity.domain == media_player.DOMAIN: level = entity.attributes.get( ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0) diff --git a/homeassistant/components/emulated_roku/.translations/th.json b/homeassistant/components/emulated_roku/.translations/th.json new file mode 100644 index 00000000000..c2570a457bc --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c IP", + "name": "\u0e0a\u0e37\u0e48\u0e2d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json index 9cb4cc33431..5ff0466c9bc 100644 --- a/homeassistant/components/emulated_roku/.translations/zh-Hans.json +++ b/homeassistant/components/emulated_roku/.translations/zh-Hans.json @@ -6,7 +6,9 @@ "step": { "user": { "data": { - "host_ip": "\u4e3b\u673aIP", + "advertise_ip": "\u5e7f\u64ad IP", + "advertise_port": "\u5e7f\u64ad\u7aef\u53e3", + "host_ip": "\u4e3b\u673a IP", "listen_port": "\u76d1\u542c\u7aef\u53e3", "name": "\u59d3\u540d" }, diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py new file mode 100644 index 00000000000..11cd4d9a804 --- /dev/null +++ b/homeassistant/components/enigma2/__init__.py @@ -0,0 +1 @@ +"""Support for Enigma2 devices.""" diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py new file mode 100644 index 00000000000..40101120f12 --- /dev/null +++ b/homeassistant/components/enigma2/media_player.py @@ -0,0 +1,225 @@ +"""Support for Enigma2 media players.""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA) +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, + SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP, MEDIA_TYPE_TVSHOW) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, + STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['openwebifpy==1.2.7'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MEDIA_CURRENTLY_RECORDING = 'media_currently_recording' +ATTR_MEDIA_DESCRIPTION = 'media_description' +ATTR_MEDIA_END_TIME = 'media_end_time' +ATTR_MEDIA_START_TIME = 'media_start_time' + +CONF_USE_CHANNEL_ICON = "use_channel_icon" + +DEFAULT_NAME = 'Enigma2 Media Player' +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = 'root' +DEFAULT_PASSWORD = 'dreambox' + +SUPPORTED_ENIGMA2 = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_VOLUME_STEP | \ + SUPPORT_TURN_ON | SUPPORT_PAUSE | SUPPORT_SELECT_SOURCE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_USE_CHANNEL_ICON, + default=DEFAULT_USE_CHANNEL_ICON): cv.boolean, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up of an enigma2 media player.""" + if discovery_info: + # Discovery gives us the streaming service port (8001) + # which is not useful as OpenWebif never runs on that port. + # So use the default port instead. + config[CONF_PORT] = DEFAULT_PORT + config[CONF_NAME] = discovery_info['hostname'] + config[CONF_HOST] = discovery_info['host'] + config[CONF_USERNAME] = DEFAULT_USERNAME + config[CONF_PASSWORD] = DEFAULT_PASSWORD + config[CONF_SSL] = DEFAULT_SSL + config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON + + from openwebif.api import CreateDevice + device = \ + CreateDevice(host=config[CONF_HOST], + port=config.get(CONF_PORT), + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + is_https=config.get(CONF_SSL), + prefer_picon=config.get(CONF_USE_CHANNEL_ICON)) + + add_devices([Enigma2Device(config[CONF_NAME], device)], True) + + +class Enigma2Device(MediaPlayerDevice): + """Representation of an Enigma2 box.""" + + def __init__(self, name, device): + """Initialize the Enigma2 device.""" + self._name = name + self.e2_box = device + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.e2_box.is_recording_playback: + return STATE_PLAYING + return STATE_OFF if self.e2_box.in_standby else STATE_ON + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return SUPPORTED_ENIGMA2 + + def turn_off(self): + """Turn off media player.""" + self.e2_box.turn_off() + + def turn_on(self): + """Turn the media player on.""" + self.e2_box.turn_on() + + @property + def media_title(self): + """Title of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_series_title(self): + """Return the title of current episode of TV show.""" + return self.e2_box.current_programme_name + + @property + def media_channel(self): + """Channel of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_content_id(self): + """Service Ref of current playing media.""" + return self.e2_box.current_service_ref + + @property + def media_content_type(self): + """Type of video currently playing.""" + return MEDIA_TYPE_TVSHOW + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.e2_box.muted + + @property + def media_image_url(self): + """Picon url for the channel.""" + return self.e2_box.picon_url + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.e2_box.set_volume(int(volume * 100)) + + def volume_up(self): + """Volume up the media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) + + def volume_down(self): + """Volume down media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.e2_box.volume + + def media_stop(self): + """Send stop command.""" + self.e2_box.set_stop() + + def media_play(self): + """Play media.""" + self.e2_box.toggle_play_pause() + + def media_pause(self): + """Pause the media player.""" + self.e2_box.toggle_play_pause() + + def media_next_track(self): + """Send next track command.""" + self.e2_box.set_channel_up() + + def media_previous_track(self): + """Send next track command.""" + self.e2_box.set_channel_down() + + def mute_volume(self, mute): + """Mute or unmute.""" + self.e2_box.mute_volume() + + @property + def source(self): + """Return the current input source.""" + return self.e2_box.current_service_channel_name + + @property + def source_list(self): + """List of available input sources.""" + return self.e2_box.source_list + + def select_source(self, source): + """Select input source.""" + self.e2_box.select_source(self.e2_box.sources[source]) + + def update(self): + """Update state of the media_player.""" + self.e2_box.update() + + @property + def device_state_attributes(self): + """Return device specific state attributes. + + isRecording: Is the box currently recording. + currservice_fulldescription: Full program description. + currservice_begin: is in the format '21:00'. + currservice_end: is in the format '21:00'. + """ + attributes = {} + if not self.e2_box.in_standby: + attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = \ + self.e2_box.status_info['isRecording'] + attributes[ATTR_MEDIA_DESCRIPTION] = \ + self.e2_box.status_info['currservice_fulldescription'] + attributes[ATTR_MEDIA_START_TIME] = \ + self.e2_box.status_info['currservice_begin'] + attributes[ATTR_MEDIA_END_TIME] = \ + self.e2_box.status_info['currservice_end'] + + return attributes diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index d2e88ed3825..8d79de2c50d 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_ID) +from homeassistant.const import (CONF_NAME, CONF_ID, POWER_WATT) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components import enocean @@ -59,4 +59,4 @@ class EnOceanSensor(enocean.EnOceanDevice, Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return 'W' + return POWER_WATT diff --git a/homeassistant/components/esphome/.translations/da.json b/homeassistant/components/esphome/.translations/da.json index 20224ec0d15..76389c45149 100644 --- a/homeassistant/components/esphome/.translations/da.json +++ b/homeassistant/components/esphome/.translations/da.json @@ -13,9 +13,13 @@ "data": { "password": "Adgangskode" }, - "description": "Indtast venligst den adgangskode, du har angivet i din konfiguration.", + "description": "Indtast venligst den adgangskode du har angivet i din konfiguration for {name}.", "title": "Indtast adgangskode" }, + "discovery_confirm": { + "description": "Vil du tilf\u00f8je ESPHome node `{name}` til Home Assistant?", + "title": "Fandt ESPHome node" + }, "user": { "data": { "host": "V\u00e6rt", diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json index 191d930eb96..30cbf09525f 100644 --- a/homeassistant/components/esphome/.translations/de.json +++ b/homeassistant/components/esphome/.translations/de.json @@ -13,9 +13,13 @@ "data": { "password": "Passwort" }, - "description": "Bitte geben Sie das Passwort der ESPhome-Konfiguration ein:", + "description": "Bitte geben Sie das Passwort der ESPHome-Konfiguration f\u00fcr {name} ein:", "title": "Passwort eingeben" }, + "discovery_confirm": { + "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", + "title": "Gefundener ESPHome-Knoten" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json index cebe6848f1b..a52f6159797 100644 --- a/homeassistant/components/esphome/.translations/fr.json +++ b/homeassistant/components/esphome/.translations/fr.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9" + }, "error": { - "invalid_password": "Mot de passe invalide !" + "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", + "invalid_password": "Mot de passe invalide !", + "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { @@ -11,6 +16,10 @@ "description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration.", "title": "Entrer votre mot de passe" }, + "discovery_confirm": { + "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?", + "title": "N\u0153ud ESPHome d\u00e9couvert" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json index d3c51f0497f..47047a95560 100644 --- a/homeassistant/components/esphome/.translations/it.json +++ b/homeassistant/components/esphome/.translations/it.json @@ -13,7 +13,7 @@ "data": { "password": "Password" }, - "description": "Inserisci la password che hai impostato nella tua configurazione.", + "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.", "title": "Inserisci la password" }, "user": { diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json index 89831979d89..aba738f4e0f 100644 --- a/homeassistant/components/esphome/.translations/nl.json +++ b/homeassistant/components/esphome/.translations/nl.json @@ -13,9 +13,13 @@ "data": { "password": "Wachtwoord" }, - "description": "Voer het wachtwoord in dat u in uw configuratie hebt ingesteld.", + "description": "Voer het wachtwoord in dat u in uw configuratie heeft ingesteld voor {name}.", "title": "Voer wachtwoord in" }, + "discovery_confirm": { + "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", + "title": "ESPHome node ontdekt" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json index 5f166eac74a..095e8825fbd 100644 --- a/homeassistant/components/esphome/.translations/no.json +++ b/homeassistant/components/esphome/.translations/no.json @@ -16,6 +16,10 @@ "description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.", "title": "Skriv Inn Passord" }, + "discovery_confirm": { + "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", + "title": "Oppdaget ESPHome node" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 19fb581eb3f..697fbf0311e 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -13,9 +13,13 @@ "data": { "password": "Has\u0142o" }, - "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji.", + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {nazwa}.", "title": "Wprowad\u017a has\u0142o" }, + "discovery_confirm": { + "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome ` {name} ` do Home Assistant?", + "title": "Znaleziono w\u0119ze\u0142 ESPHome " + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json index 6eadcb4e18e..da977af601a 100644 --- a/homeassistant/components/esphome/.translations/sv.json +++ b/homeassistant/components/esphome/.translations/sv.json @@ -13,9 +13,13 @@ "data": { "password": "L\u00f6senord" }, - "description": "Ange det l\u00f6senord du angav i din konfiguration.", + "description": "Ange det l\u00f6senord du angett i din konfiguration f\u00f6r {name}.", "title": "Ange l\u00f6senord" }, + "discovery_confirm": { + "description": "Vill du l\u00e4gga till ESPHome noden ` {name} ` till Home Assistant?", + "title": "Uppt\u00e4ckt ESPHome-nod" + }, "user": { "data": { "host": "V\u00e4rddatorn", diff --git a/homeassistant/components/esphome/.translations/th.json b/homeassistant/components/esphome/.translations/th.json new file mode 100644 index 00000000000..ceab9b6e11b --- /dev/null +++ b/homeassistant/components/esphome/.translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07!" + }, + "step": { + "authenticate": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "title": "\u0e43\u0e2a\u0e48\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json index 8e5ca59fcef..0a8211be449 100644 --- a/homeassistant/components/esphome/.translations/zh-Hans.json +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -1,16 +1,16 @@ { "config": { "error": { - "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230ESP\u3002\u8bf7\u786e\u4fdd\u60a8\u7684YAML\u6587\u4ef6\u5305\u542b'api:'\u884c\u3002", + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", - "resolve_error": "\u65e0\u6cd5\u89e3\u6790ESP\u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u4ecd\u7136\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { "data": { "password": "\u5bc6\u7801" }, - "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u4e3a\u201c{name}\u201d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", "title": "\u8f93\u5165\u5bc6\u7801" }, "user": { diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index be9212793ab..617c4902068 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -7,7 +7,8 @@ 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 +from homeassistant.const import (ATTR_TEMPERATURE, TEMP_CELSIUS, + ENERGY_KILO_WATT_HOUR) DEPENDENCIES = ['fritzbox'] @@ -15,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR ATTR_TEMPERATURE_UNIT = 'temperature_unit' diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0e674740269..6e05299ec52 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190305.1'] +REQUIREMENTS = ['home-assistant-frontend==20190320.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', @@ -407,7 +407,7 @@ class IndexView(HomeAssistantView): }) no_auth = '1' - if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: + if not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' diff --git a/homeassistant/components/geofency/.translations/zh-Hans.json b/homeassistant/components/geofency/.translations/zh-Hans.json index 7ab8a128980..d18d8bc8280 100644 --- a/homeassistant/components/geofency/.translations/zh-Hans.json +++ b/homeassistant/components/geofency/.translations/zh-Hans.json @@ -2,7 +2,10 @@ "config": { "abort": { "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002", - "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e Geofency \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" }, "step": { "user": { diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index f20a4106a16..8afa55acc5c 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,4 +1,5 @@ """Helper classes for Google Assistant integration.""" +from homeassistant.core import Context class SmartHomeError(Exception): @@ -16,10 +17,19 @@ class SmartHomeError(Exception): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, allow_unlock, agent_user_id, + def __init__(self, should_expose, allow_unlock, entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose - self.agent_user_id = agent_user_id self.entity_config = entity_config or {} self.allow_unlock = allow_unlock + + +class RequestData: + """Hold data associated with a particular request.""" + + def __init__(self, config, user_id, request_id): + """Initialize the request data.""" + self.config = config + self.request_id = request_id + self.context = Context(user_id=user_id) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f0294c3bcb2..cbe2015f4f9 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -71,17 +71,16 @@ class GoogleAssistantView(HomeAssistantView): def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" - self.is_exposed = is_exposed - self.entity_config = entity_config - self.allow_unlock = allow_unlock + self.config = Config(is_exposed, + allow_unlock, + entity_config) async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict - config = Config(self.is_exposed, - self.allow_unlock, - request['hass_user'].id, - self.entity_config) result = await async_handle_message( - request.app['hass'], config, message) + request.app['hass'], + self.config, + request['hass_user'].id, + message) return self.json(result) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 21316c62085..fa272c25012 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,4 +1,5 @@ """Support for Google Assistant Smart Home API.""" +from asyncio import gather from collections.abc import Mapping from itertools import product import logging @@ -35,7 +36,7 @@ from .const import ( ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import SmartHomeError +from .helpers import SmartHomeError, RequestData HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -86,11 +87,11 @@ class _GoogleEntity: domain = state.domain features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS + return [Trait(self.hass, state, self.config) + for Trait in trait.TRAITS if Trait.supported(domain, features)] - @callback - def sync_serialize(self): + async def sync_serialize(self): """Serialize entity for a SYNC response. https://developers.google.com/actions/smarthome/create-app#actiondevicessync @@ -132,13 +133,31 @@ class _GoogleEntity: if aliases: device['name']['nicknames'] = aliases - # add room hint if annotated + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + room = entity_config.get(CONF_ROOM_HINT) if room: device['roomHint'] = room + return device - for trt in traits: - device['attributes'].update(trt.sync_attributes()) + dev_reg, ent_reg, area_reg = await gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.area_registry.async_get_registry(), + ) + + entity_entry = ent_reg.async_get(state.entity_id) + if not (entity_entry and entity_entry.device_id): + return device + + device_entry = dev_reg.devices.get(entity_entry.device_id) + if not (device_entry and device_entry.area_id): + return device + + area_entry = area_reg.areas.get(device_entry.area_id) + if area_entry and area_entry.name: + device['roomHint'] = area_entry.name return device @@ -160,7 +179,7 @@ class _GoogleEntity: return attrs - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a command. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -168,7 +187,7 @@ class _GoogleEntity: executed = False for trt in self.traits(): if trt.can_execute(command, params): - await trt.execute(command, params) + await trt.execute(command, data, params) executed = True break @@ -184,9 +203,13 @@ class _GoogleEntity: self.state = self.hass.states.get(self.entity_id) -async def async_handle_message(hass, config, message): +async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - response = await _process(hass, config, message) + request_id = message.get('requestId') # type: str + + data = RequestData(config, user_id, request_id) + + response = await _process(hass, data, message) if response and 'errorCode' in response['payload']: _LOGGER.error('Error handling message %s: %s', @@ -195,14 +218,13 @@ async def async_handle_message(hass, config, message): return response -async def _process(hass, config, message): +async def _process(hass, data, message): """Process a message.""" - request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list if len(inputs) != 1: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } @@ -210,50 +232,50 @@ async def _process(hass, config, message): if handler is None: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } try: - result = await handler(hass, config, request_id, - inputs[0].get('payload')) + result = await handler(hass, data, inputs[0].get('payload')) except SmartHomeError as err: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': err.code} } except Exception: # pylint: disable=broad-except _LOGGER.exception('Unexpected error') return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_UNKNOWN_ERROR} } if result is None: return None - return {'requestId': request_id, 'payload': result} + return {'requestId': data.request_id, 'payload': result} @HANDLERS.register('action.devices.SYNC') -async def async_devices_sync(hass, config, request_id, payload): +async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. https://developers.google.com/actions/smarthome/create-app#actiondevicessync """ - hass.bus.async_fire(EVENT_SYNC_RECEIVED, { - 'request_id': request_id - }) + hass.bus.async_fire( + EVENT_SYNC_RECEIVED, + {'request_id': data.request_id}, + context=data.context) devices = [] for state in hass.states.async_all(): if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - if not config.should_expose(state): + if not data.config.should_expose(state): continue - entity = _GoogleEntity(hass, config, state) - serialized = entity.sync_serialize() + entity = _GoogleEntity(hass, data.config, state) + serialized = await entity.sync_serialize() if serialized is None: _LOGGER.debug("No mapping for %s domain", entity.state) @@ -262,7 +284,7 @@ async def async_devices_sync(hass, config, request_id, payload): devices.append(serialized) response = { - 'agentUserId': config.agent_user_id, + 'agentUserId': data.context.user_id, 'devices': devices, } @@ -270,7 +292,7 @@ async def async_devices_sync(hass, config, request_id, payload): @HANDLERS.register('action.devices.QUERY') -async def async_devices_query(hass, config, request_id, payload): +async def async_devices_query(hass, data, payload): """Handle action.devices.QUERY request. https://developers.google.com/actions/smarthome/create-app#actiondevicesquery @@ -280,23 +302,27 @@ async def async_devices_query(hass, config, request_id, payload): devid = device['id'] state = hass.states.get(devid) - hass.bus.async_fire(EVENT_QUERY_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: devid, - }) + hass.bus.async_fire( + EVENT_QUERY_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: devid, + }, + context=data.context) if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} continue - devices[devid] = _GoogleEntity(hass, config, state).query_serialize() + entity = _GoogleEntity(hass, data.config, state) + devices[devid] = entity.query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -async def handle_devices_execute(hass, config, request_id, payload): +async def handle_devices_execute(hass, data, payload): """Handle action.devices.EXECUTE request. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -309,11 +335,14 @@ async def handle_devices_execute(hass, config, request_id, payload): command['execution']): entity_id = device['id'] - hass.bus.async_fire(EVENT_COMMAND_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: entity_id, - 'execution': execution - }) + hass.bus.async_fire( + EVENT_COMMAND_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: entity_id, + 'execution': execution + }, + context=data.context) # Happens if error occurred. Skip entity for further processing if entity_id in results: @@ -330,10 +359,11 @@ async def handle_devices_execute(hass, config, request_id, payload): } continue - entities[entity_id] = _GoogleEntity(hass, config, state) + entities[entity_id] = _GoogleEntity(hass, data.config, state) try: await entities[entity_id].execute(execution['command'], + data, execution.get('params', {})) except SmartHomeError as err: results[entity_id] = { @@ -360,7 +390,7 @@ async def handle_devices_execute(hass, config, request_id, payload): @HANDLERS.register('action.devices.DISCONNECT') -async def async_devices_disconnect(hass, config, request_id, payload): +async def async_devices_disconnect(hass, data, payload): """Handle action.devices.DISCONNECT request. https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d0368ee0775..aff24f30512 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -102,7 +102,7 @@ class _Trait: """Test if command can be executed.""" return command in self.commands - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a trait command.""" raise NotImplementedError @@ -159,7 +159,7 @@ class BrightnessTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a brightness command.""" domain = self.state.domain @@ -168,20 +168,20 @@ class BrightnessTrait(_Trait): light.DOMAIN, light.SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == cover.DOMAIN: await self.hass.services.async_call( cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { ATTR_ENTITY_ID: self.state.entity_id, cover.ATTR_POSITION: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == media_player.DOMAIN: await self.hass.services.async_call( media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: params['brightness'] / 100 - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -221,7 +221,7 @@ class OnOffTrait(_Trait): return {'on': self.state.state != cover.STATE_CLOSED} return {'on': self.state.state != STATE_OFF} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an OnOff command.""" domain = self.state.domain @@ -242,7 +242,7 @@ class OnOffTrait(_Trait): await self.hass.services.async_call(service_domain, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -288,7 +288,7 @@ class ColorSpectrumTrait(_Trait): return (command in self.commands and 'spectrumRGB' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color spectrum command.""" # Convert integer to hex format and left pad with 0's till length 6 hex_value = "{0:06x}".format(params['color']['spectrumRGB']) @@ -298,7 +298,7 @@ class ColorSpectrumTrait(_Trait): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -355,7 +355,7 @@ class ColorTemperatureTrait(_Trait): return (command in self.commands and 'temperature' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color temperature command.""" temp = color_util.color_temperature_kelvin_to_mired( params['color']['temperature']) @@ -371,7 +371,7 @@ class ColorTemperatureTrait(_Trait): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp, - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -400,13 +400,14 @@ class SceneTrait(_Trait): """Return scene query attributes.""" return {} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( self.state.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=self.state.domain != script.DOMAIN) + }, blocking=self.state.domain != script.DOMAIN, + context=data.context) @register_trait @@ -434,12 +435,12 @@ class DockTrait(_Trait): """Return dock query attributes.""" return {'isDocked': self.state.state == vacuum.STATE_DOCKED} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a dock command.""" await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -473,30 +474,30 @@ class StartStopTrait(_Trait): 'isPaused': self.state.state == vacuum.STATE_PAUSED, } - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a StartStop command.""" if command == COMMAND_STARTSTOP: if params['start']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_STOP, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_PAUSEUNPAUSE: if params['pause']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_PAUSE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -584,7 +585,7 @@ class TemperatureSettingTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a temperature point or mode command.""" # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit @@ -608,7 +609,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: temp_high = temp_util.convert( @@ -640,7 +641,7 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_TARGET_TEMP_HIGH: temp_high, climate.ATTR_TARGET_TEMP_LOW: temp_low, - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_SET_MODE: await self.hass.services.async_call( @@ -648,7 +649,7 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_OPERATION_MODE: self.google_to_hass[params['thermostatMode']], - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -681,7 +682,7 @@ class LockUnlockTrait(_Trait): allowed_unlock = not params['lock'] and self.config.allow_unlock return params['lock'] or allowed_unlock - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an LockUnlock command.""" if params['lock']: service = lock.SERVICE_LOCK @@ -690,7 +691,7 @@ class LockUnlockTrait(_Trait): await self.hass.services.async_call(lock.DOMAIN, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -760,13 +761,13 @@ class FanSpeedTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetFanSpeed command.""" await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, { ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -934,7 +935,7 @@ class ModesTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetModes command.""" settings = params.get('updateModeSettings') requested_source = settings.get( @@ -951,4 +952,4 @@ class ModesTrait(_Trait): media_player.SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_INPUT_SOURCE: source - }, blocking=True) + }, blocking=True, context=data.context) diff --git a/homeassistant/components/gpslogger/.translations/cs.json b/homeassistant/components/gpslogger/.translations/cs.json new file mode 100644 index 00000000000..f79a9f5d739 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Instalace dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu, aby p\u0159ij\u00edmala zpr\u00e1vy od spole\u010dnosti GPSLogger.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit funkci Webhook v n\u00e1stroji GPSLogger. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit GPSLogger Webhook?", + "title": "Nastavit GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json index d0dece65a0f..4956cf52f26 100644 --- a/homeassistant/components/gpslogger/.translations/nl.json +++ b/homeassistant/components/gpslogger/.translations/nl.json @@ -1,5 +1,11 @@ { "config": { + "step": { + "user": { + "description": "Weet je zeker dat je de GPSLogger Webhook wilt instellen?", + "title": "Configureer de GPSLogger Webhook" + } + }, "title": "GPSLogger Webhook" } } \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hans.json b/homeassistant/components/gpslogger/.translations/zh-Hans.json index dd5db73f582..91d3ac74994 100644 --- a/homeassistant/components/gpslogger/.translations/zh-Hans.json +++ b/homeassistant/components/gpslogger/.translations/zh-Hans.json @@ -2,6 +2,9 @@ "config": { "abort": { "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e GPSLogger \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" } } } \ No newline at end of file diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index e0315209ba1..80ac01a78ac 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -300,8 +300,8 @@ async def async_setup(hass, config): visible = service.data.get(ATTR_VISIBLE) tasks = [] - for group in component.async_extract_from_service(service, - expand_group=False): + for group in await component.async_extract_from_service( + service, expand_group=False): group.visible = visible tasks.append(group.async_update_ha_state()) diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/.translations/th.json new file mode 100644 index 00000000000..ae7fc861b77 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "description": "\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 4ea199bdcd1..78f2674243c 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -7,9 +7,8 @@ import voluptuous as vol from homeassistant.components import remote from homeassistant.components.remote import ( - ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_NUM_REPEATS, - DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA -) + ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP ) @@ -340,8 +339,12 @@ class HarmonyRemote(remote.RemoteDevice): _LOGGER.error("%s: Device %s is invalid", self.name, device) return - num_repeats = kwargs.get(ATTR_NUM_REPEATS) + num_repeats = kwargs[ATTR_NUM_REPEATS] delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + hold_secs = kwargs[ATTR_HOLD_SECS] + _LOGGER.debug("Sending commands to device %s holding for %s seconds " + "with a delay of %s seconds", + device, hold_secs, delay_secs) # Creating list of commands to send. snd_cmnd_list = [] @@ -350,7 +353,7 @@ class HarmonyRemote(remote.RemoteDevice): send_command = SendCommandDevice( device=device_id, command=single_command, - delay=0 + delay=hold_secs ) snd_cmnd_list.append(send_command) if delay_secs > 0: diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e070c889f31..7f85c8cfc3f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG +import homeassistant.config as conf_util from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) from homeassistant.core import DOMAIN as HASS_DOMAIN, callback @@ -130,23 +131,6 @@ def is_hassio(hass): return DOMAIN in hass.config.components -@bind_hass -async def async_check_config(hass): - """Check configuration over Hass.io API.""" - hassio = hass.data[DOMAIN] - - try: - result = await hassio.check_homeassistant_config() - except HassioAPIError as err: - _LOGGER.error("Error on Hass.io API: %s", err) - raise HomeAssistantError() from None - else: - if result['result'] == "error": - return result['message'] - - return None - - async def async_setup(hass, config): """Set up the Hass.io component.""" # Check local setup @@ -259,9 +243,13 @@ async def async_setup(hass, config): await hassio.stop_homeassistant() return - error = await async_check_config(hass) - if error: - _LOGGER.error(error) + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) hass.components.persistent_notification.async_create( "Config error. See dev-info panel for details.", "Config validating", "{0}.check_config".format(HASS_DOMAIN)) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index b104d53aff9..05c183ccd60 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -57,9 +57,9 @@ class HassIOAuth(HomeAssistantView): def _get_provider(self): """Return Homeassistant auth provider.""" - for prv in self.hass.auth.auth_providers: - if prv.type == 'homeassistant': - return prv + prv = self.hass.auth.get_auth_provider('homeassistant', None) + if prv is not None: + return prv _LOGGER.error("Can't find Home Assistant auth.") raise HTTPNotFound() diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 640ed29e578..7eb3245c0df 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -7,8 +7,10 @@ import aiohttp import async_timeout from homeassistant.components.http import ( - CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, - CONF_SSL_CERTIFICATE) + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, +) from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT from .const import X_HASSIO @@ -95,13 +97,6 @@ class HassIO: """ return self.send_command("/homeassistant/stop") - def check_homeassistant_config(self): - """Check Home-Assistant config with Hass.io API. - - This method return a coroutine. - """ - return self.send_command("/homeassistant/check", timeout=600) - @_api_data def retrieve_discovery_messages(self): """Return all discovery data from Hass.io API. @@ -125,7 +120,6 @@ class HassIO: options = { 'ssl': CONF_SSL_CERTIFICATE in http_config, 'port': port, - 'password': http_config.get(CONF_API_PASSWORD), 'watchdog': True, 'refresh_token': refresh_token, } diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json new file mode 100644 index 00000000000..e53fb5f9def --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Accessori ja configurat amb aquest controlador.", + "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.", + "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.", + "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.", + "no_devices": "No s'han trobat dispositius desvinculats." + }, + "error": { + "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.", + "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.", + "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Codi de vinculaci\u00f3" + }, + "description": "Introdueix el codi de vinculaci\u00f3 HomeKit per utilitzar aquest accessori", + "title": "Vinculaci\u00f3 amb {{ model }}" + }, + "user": { + "data": { + "device": "Dispositiu" + }, + "description": "Selecciona el dispositiu amb el qual et vols vincular", + "title": "Vinculaci\u00f3 amb un accessori HomeKit" + } + }, + "title": "Acessori HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/cy.json b/homeassistant/components/homekit_controller/.translations/cy.json new file mode 100644 index 00000000000..59e402080f3 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/cy.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "ignored_model": "Mae cymorth HomeKit ar gyfer y model hwn wedi'i rwystro gan fod integreiddiad cynhenid mwy cyflawn ar gael.", + "invalid_config_entry": "Mae'r ddyfais yn dangos bod eisoes wedi paru ond mae cofnod ffurwedd groes amdano yn Home Assistant sydd angen ei diddymu", + "no_devices": "Ni ellir ddod o hyd i ddyfeisiau heb eu paru" + }, + "error": { + "authentication_error": "Cod HomeKit anghywir. Gwiriwch a cheisiwch eto.", + "unable_to_pair": "Methu paru, pl\u00eds ceisiwch eto", + "unknown_error": "Dyfeis wedi adrodd gwall anhysbys. Methodd paru." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Cod Paru" + }, + "description": "Rhowch eich cod paru HomeKit i ddefnyddio'r ategolyn hwn", + "title": "Paru gyda ategolyn HomeKit" + }, + "user": { + "data": { + "device": "Dyfais" + }, + "description": "Dewiswch y ddyfais rydych eisiau paru efo", + "title": "Paru gyda ategolyn HomeKit" + } + }, + "title": "Ategolyn HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json new file mode 100644 index 00000000000..1f2dfe66dd2 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.", + "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.", + "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.", + "invalid_config_entry": "Dieses Ger\u00e4t wird als bereit zum Koppeln angezeigt, es gibt jedoch bereits einen widerspr\u00fcchlichen Konfigurationseintrag in Home Assistant, der zuerst entfernt werden muss.", + "no_devices": "Keine ungekoppelten Ger\u00e4te gefunden" + }, + "error": { + "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.", + "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut", + "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Kopplungscode" + }, + "description": "Geben Sie Ihren HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", + "title": "Kopplung mit {{ model }}" + }, + "user": { + "data": { + "device": "Ger\u00e4t" + }, + "description": "W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest", + "title": "Mit HomeKit Zubeh\u00f6r koppeln" + } + }, + "title": "HomeKit Zubeh\u00f6r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json new file mode 100644 index 00000000000..591e035ed18 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Accessory is already configured with this controller.", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", + "no_devices": "No unpaired devices could be found" + }, + "error": { + "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Enter your HomeKit pairing code to use this accessory", + "title": "Pair with HomeKit Accessory" + }, + "user": { + "data": { + "device": "Device" + }, + "description": "Select the device you want to pair with", + "title": "Pair with HomeKit Accessory" + } + }, + "title": "HomeKit Accessory" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json new file mode 100644 index 00000000000..1b1edbd5146 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", + "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", + "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", + "invalid_config_entry": "Este dispositivo se muestra como listo para emparejar pero ya existe una entrada de configuraci\u00f3n conflictiva en Home Assistant que debe ser eliminada primero.", + "no_devices": "No se encontraron dispositivos no emparejados" + }, + "error": { + "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", + "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", + "unknown_error": "El dispositivo report\u00f3 un error desconocido. El emparejamiento ha fallado." + }, + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de Emparejamiento" + }, + "description": "Introduce tu c\u00f3digo de emparejamiento HomeKit para usar este accesorio", + "title": "Emparejar con accesorio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecciona el dispositivo que desea emparejar", + "title": "Emparejar con accesorio HomeKit" + } + }, + "title": "Accesorio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json new file mode 100644 index 00000000000..525604cd96b --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "invalid_config_entry": "\uc774 \uc7a5\uce58\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", + "no_devices": "\ud398\uc5b4\ub9c1\ub418\uc9c0 \uc54a\uc740 \uc7a5\uce58\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "error": { + "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "pair": { + "data": { + "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc" + }, + "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" + }, + "user": { + "data": { + "device": "\uc7a5\uce58" + }, + "description": "\ud398\uc5b4\ub9c1 \ud560 \uc7a5\uce58\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" + } + }, + "title": "HomeKit \uc561\uc138\uc11c\ub9ac" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json new file mode 100644 index 00000000000..6d338689d1f --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", + "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.", + "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.", + "invalid_config_entry": "D\u00ebsen Apparat mellt sech prett fir ze verbanne mee et g\u00ebtt schonn eng Entr\u00e9e am Home Assistant d\u00e9i ee Konflikt duerstellt welch fir d'\u00e9ischt muss erausgeholl ginn.", + "no_devices": "Keng net verbonnen Apparater fonnt" + }, + "error": { + "authentication_error": "Ong\u00ebltege HomeKit Code. Iwwerpr\u00e9ift d\u00ebsen an prob\u00e9iert w.e.g. nach emol.", + "unable_to_pair": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "unknown_error": "Apparat mellt een onbekannte Feeler. Verbindung net m\u00e9iglech." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Gitt \u00e4ren HomeKit pairing Code an fir d\u00ebsen Accessoire ze benotzen", + "title": "Mat {{ model }} verbannen" + }, + "user": { + "data": { + "device": "Apparat" + }, + "description": "Wielt den Apparat aus dee soll verbonne ginn", + "title": "Mam HomeKit Accessoire verbannen" + } + }, + "title": "HomeKit Accessoire" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json new file mode 100644 index 00000000000..fcabd40d3be --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "invalid_config_entry": "Dit apparaat geeft aan dat het gereed is om te koppelen, maar er is al een conflicterend configuratie-item voor in de Home Assistant dat eerst moet worden verwijderd.", + "no_devices": "Er zijn geen gekoppelde apparaten gevonden" + }, + "error": { + "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.", + "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Koppelingscode" + }, + "title": "Koppel met {{model}}" + }, + "user": { + "data": { + "device": "Apparaat" + }, + "description": "Selecteer het apparaat waarmee u wilt koppelen", + "title": "Koppel met HomeKit accessoire" + } + }, + "title": "HomeKit Accessoires" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json new file mode 100644 index 00000000000..53250833755 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", + "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", + "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", + "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", + "no_devices": "Ingen ukoblede enheter ble funnet" + }, + "error": { + "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.", + "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", + "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Sammenkoblingskode" + }, + "description": "Skriv inn HomeKit sammenkoblingskoden for \u00e5 bruke dette tilbeh\u00f8ret", + "title": "Sammenkoble {{ model }}" + }, + "user": { + "data": { + "device": "Enhet" + }, + "description": "Velg enheten du vil koble til", + "title": "Koble til HomeKit tilbeh\u00f8r" + } + }, + "title": "HomeKit tilbeh\u00f8r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json new file mode 100644 index 00000000000..11efebf250e --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", + "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", + "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", + "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", + "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144" + }, + "error": { + "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", + "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.", + "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Kod parowania" + }, + "description": "Wprowad\u017a kod parowania HomeKit, aby u\u017cy\u0107 tego akcesorium", + "title": "Sparuj z akcesorium HomeKit" + }, + "user": { + "data": { + "device": "Urz\u0105dzenie" + }, + "description": "Wybierz urz\u0105dzenie, kt\u00f3re chcesz sparowa\u0107", + "title": "Sparuj z akcesorium HomeKit" + } + }, + "title": "Akcesorium HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json new file mode 100644 index 00000000000..983afda5e9d --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", + "invalid_config_entry": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0433\u043e\u0442\u043e\u0432\u043e\u0435 \u043a \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044e, \u043d\u043e \u0432 Home Assistant \u0443\u0436\u0435 \u0435\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0435\u0433\u043e, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c.", + "no_devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0435 \u0434\u043b\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "error": { + "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c." + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit, \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" + }, + "user": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0441 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0443\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" + } + }, + "title": "HomeKit Accessory" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/th.json b/homeassistant/components/homekit_controller/.translations/th.json new file mode 100644 index 00000000000..a67945c8135 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/th.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e44\u0e14\u0e49\u0e23\u0e31\u0e1a\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e14\u0e49\u0e27\u0e22\u0e15\u0e31\u0e27\u0e04\u0e27\u0e1a\u0e04\u0e38\u0e21\u0e19\u0e35\u0e49\u0e41\u0e25\u0e49\u0e27", + "already_paired": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e2d\u0e37\u0e48\u0e19\u0e41\u0e25\u0e49\u0e27 \u0e42\u0e1b\u0e23\u0e14\u0e23\u0e35\u0e40\u0e0b\u0e47\u0e15\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e41\u0e25\u0e49\u0e27\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "ignored_model": "\u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c HomeKit \u0e23\u0e38\u0e48\u0e19\u0e19\u0e35\u0e49\u0e16\u0e39\u0e01\u0e1b\u0e34\u0e14\u0e01\u0e31\u0e49\u0e19\u0e44\u0e27\u0e49 \u0e41\u0e15\u0e48\u0e01\u0e47\u0e21\u0e35\u0e01\u0e32\u0e23\u0e17\u0e33\u0e07\u0e32\u0e19\u0e1a\u0e32\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e17\u0e35\u0e48\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19\u0e44\u0e14\u0e49", + "invalid_config_entry": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e19\u0e35\u0e49\u0e1a\u0e2d\u0e01\u0e27\u0e48\u0e32\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e17\u0e35\u0e48\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 \u0e41\u0e15\u0e48\u0e21\u0e31\u0e19\u0e21\u0e35\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48\u0e02\u0e31\u0e14\u0e41\u0e22\u0e49\u0e07\u0e01\u0e31\u0e19\u0e2d\u0e22\u0e39\u0e48 Home Assistant \u0e40\u0e25\u0e22\u0e17\u0e33\u0e01\u0e32\u0e23\u0e25\u0e1a\u0e17\u0e34\u0e49\u0e07", + "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e08\u0e30\u0e43\u0e0a\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e43\u0e14\u0e46 \u0e40\u0e25\u0e22" + }, + "error": { + "authentication_error": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e41\u0e25\u0e30\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unable_to_pair": "\u0e44\u0e21\u0e48\u0e2a\u0e32\u0e21\u0e32\u0e23\u0e16\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e44\u0e14\u0e49 \u0e42\u0e1b\u0e23\u0e14\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unknown_error": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e23\u0e32\u0e22\u0e07\u0e32\u0e19\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48" + }, + "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e0a\u0e49\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a {{ model }}" + }, + "user": { + "data": { + "device": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + }, + "description": "\u0e40\u0e25\u0e37\u0e2d\u0e01\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } + }, + "title": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/vi.json b/homeassistant/components/homekit_controller/.translations/vi.json new file mode 100644 index 00000000000..cc16ebc70c4 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/vi.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "pair": { + "data": { + "pairing_code": "M\u00e3 k\u1ebft n\u1ed1i" + }, + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + }, + "user": { + "data": { + "device": "Thi\u1ebft b\u1ecb" + }, + "description": "Ch\u1ecdn thi\u1ebft b\u1ecb b\u1ea1n mu\u1ed1n k\u1ebft n\u1ed1i", + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + } + }, + "title": "Ph\u1ee5 ki\u1ec7n HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json new file mode 100644 index 00000000000..a83b5be1f0a --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u914d\u4ef6\u5df2\u901a\u8fc7\u6b64\u63a7\u5236\u5668\u914d\u7f6e\u5b8c\u6210\u3002", + "already_paired": "\u6b64\u914d\u4ef6\u5df2\u4e0e\u53e6\u4e00\u53f0\u8bbe\u5907\u914d\u5bf9\u3002\u8bf7\u91cd\u7f6e\u914d\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002", + "ignored_model": "HomeKit \u5bf9\u6b64\u8bbe\u5907\u7684\u652f\u6301\u5df2\u88ab\u963b\u6b62\uff0c\u56e0\u4e3a\u6709\u529f\u80fd\u66f4\u5b8c\u6574\u7684\u539f\u751f\u96c6\u6210\u53ef\u4ee5\u4f7f\u7528\u3002", + "invalid_config_entry": "\u6b64\u8bbe\u5907\u5df2\u51c6\u5907\u597d\u914d\u5bf9\uff0c\u4f46\u662f Home Assistant \u4e2d\u5b58\u5728\u4e0e\u4e4b\u51b2\u7a81\u7684\u914d\u7f6e\uff0c\u5fc5\u987b\u5148\u5c06\u5176\u5220\u9664\u3002", + "no_devices": "\u6ca1\u6709\u627e\u5230\u672a\u914d\u5bf9\u7684\u8bbe\u5907" + }, + "error": { + "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", + "unknown_error": "\u8bbe\u5907\u62a5\u544a\u4e86\u672a\u77e5\u9519\u8bef\u3002\u914d\u5bf9\u5931\u8d25\u3002" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u914d\u5bf9\u4ee3\u7801" + }, + "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "title": "\u4e0e {{model}} \u914d\u5bf9" + }, + "user": { + "data": { + "device": "\u8bbe\u5907" + }, + "description": "\u9009\u62e9\u60a8\u8981\u914d\u5bf9\u7684\u8bbe\u5907", + "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json new file mode 100644 index 00000000000..cbe819fdaeb --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210", + "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002", + "invalid_config_entry": "\u88dd\u7f6e\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", + "no_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u914d\u5c0d\u88dd\u7f6e" + }, + "error": { + "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc" + }, + "description": "\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u4ee3\u78bc", + "title": "{{ model }} \u914d\u5c0d" + }, + "user": { + "data": { + "device": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u88dd\u7f6e", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index eb748a3d883..ec38cf881d6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,4 +1,5 @@ """Support for Homekit device discovery.""" +import asyncio import json import logging import os @@ -8,35 +9,22 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import call_later -REQUIREMENTS = ['homekit==0.12.2'] +from .const import ( + CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_ACCESSORIES, + KNOWN_DEVICES +) + + +REQUIREMENTS = ['homekit[IP]==0.13.0'] -DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' -# Mapping from Homekit type to component. -HOMEKIT_ACCESSORY_DISPATCH = { - 'lightbulb': 'light', - 'outlet': 'switch', - 'switch': 'switch', - 'thermostat': 'climate', - 'security-system': 'alarm_control_panel', - 'garage-door-opener': 'cover', - 'window': 'cover', - 'window-covering': 'cover', - 'lock-mechanism': 'lock', - 'motion': 'binary_sensor', -} - HOMEKIT_IGNORE = [ 'BSB002', 'Home Assistant Bridge', 'TRADFRI gateway', ] -KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) -KNOWN_DEVICES = "{}-devices".format(DOMAIN) -CONTROLLER = "{}-controller".format(DOMAIN) - _LOGGER = logging.getLogger(__name__) REQUEST_TIMEOUT = 5 # seconds @@ -87,6 +75,8 @@ class HKDevice(): self.configurator = hass.components.configurator self._connection_warning_logged = False + self.pairing_lock = asyncio.Lock(loop=hass.loop) + self.pairing = self.controller.pairings.get(hkid) if self.pairing is not None: @@ -181,12 +171,39 @@ class HKDevice(): 'name': 'HomeKit code', 'type': 'string'}]) + async def get_characteristics(self, *args, **kwargs): + """Read latest state from homekit accessory.""" + async with self.pairing_lock: + chars = await self.hass.async_add_executor_job( + self.pairing.get_characteristics, + *args, + **kwargs, + ) + return chars + + async def put_characteristics(self, characteristics): + """Control a HomeKit device state from Home Assistant.""" + chars = [] + for row in characteristics: + chars.append(( + row['aid'], + row['iid'], + row['value'], + )) + + async with self.pairing_lock: + await self.hass.async_add_executor_job( + self.pairing.put_characteristics, + chars + ) + class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" def __init__(self, accessory, devinfo): """Initialise a generic HomeKit device.""" + self._available = True self._name = accessory.model self._accessory = accessory self._aid = devinfo['aid'] @@ -251,17 +268,27 @@ class HomeKitEntity(Entity): # pylint: disable=not-callable setup_fn(char) - def update(self): + async def async_update(self): """Obtain a HomeKit device's state.""" # pylint: disable=import-error - from homekit.exceptions import AccessoryDisconnectedError - - pairing = self._accessory.pairing + from homekit.exceptions import ( + AccessoryDisconnectedError, AccessoryNotFoundError) try: - new_values_dict = pairing.get_characteristics(self._chars_to_poll) - except AccessoryDisconnectedError: + new_values_dict = await self._accessory.get_characteristics( + self._chars_to_poll + ) + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. + self._available = False return + except AccessoryDisconnectedError: + # Temporary connection failure. Device is still available but our + # connection was dropped. + return + + self._available = True for (_, iid), result in new_values_dict.items(): if 'value' not in result: @@ -287,34 +314,18 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.pairing is not None + return self._available def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" raise NotImplementedError - def update_characteristics(self, characteristics): - """Synchronise a HomeKit device state with Home Assistant.""" - pass - - def put_characteristics(self, characteristics): - """Control a HomeKit device state from Home Assistant.""" - chars = [] - for row in characteristics: - chars.append(( - row['aid'], - row['iid'], - row['value'], - )) - - self._accessory.pairing.put_characteristics(chars) - def setup(hass, config): """Set up for Homekit devices.""" # pylint: disable=import-error import homekit - from homekit.controller import Pairing + from homekit.controller.ip_implementation import IpPairing hass.data[CONTROLLER] = controller = homekit.Controller() @@ -335,7 +346,7 @@ def setup(hass, config): continue with open(os.path.join(data_dir, device)) as pairing_data_fp: pairing_data = json.load(pairing_data_fp) - controller.pairings[alias] = Pairing(pairing_data) + controller.pairings[alias] = IpPairing(pairing_data) controller.save_data(pairing_file) def discovery_dispatch(service, discovery_info): diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 5d366b6e27b..61352c3bedc 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -74,28 +74,28 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): """Return the state of the device.""" return self._state - def alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None): """Send disarm command.""" - self.set_alarm_state(STATE_ALARM_DISARMED, code) + await self.set_alarm_state(STATE_ALARM_DISARMED, code) - def alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None): """Send arm command.""" - self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) + await self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) - def alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None): """Send stay command.""" - self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) + await self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) - def alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code=None): """Send night command.""" - self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) + await self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) - def set_alarm_state(self, state, code=None): + async def set_alarm_state(self, state, code=None): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['security-system-state.target'], 'value': TARGET_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index ceadcd46b9d..8696d2b1f97 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -80,21 +80,21 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): def _update_temperature_target(self, value): self._target_temp = value - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) characteristics = [{'aid': self._aid, 'iid': self._chars['temperature.target'], 'value': temp}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set new target operation mode.""" characteristics = [{'aid': self._aid, 'iid': self._chars['heating-cooling.target'], 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def state(self): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py new file mode 100644 index 00000000000..1cd66896fe2 --- /dev/null +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -0,0 +1,257 @@ +"""Config flow to configure homekit_controller.""" +import os +import json +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback + +from .const import DOMAIN, KNOWN_DEVICES +from .connection import get_bridge_information, get_accessory_name + + +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway', +] +HOMEKIT_DIR = '.homekit' +PAIRING_FILE = 'pairing.json' + +_LOGGER = logging.getLogger(__name__) + + +def load_old_pairings(hass): + """Load any old pairings from on-disk json fragments.""" + old_pairings = {} + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + pairing_file = os.path.join(data_dir, PAIRING_FILE) + + # Find any pairings created with in HA 0.85 / 0.86 + if os.path.exists(pairing_file): + with open(pairing_file) as pairing_file: + old_pairings.update(json.load(pairing_file)) + + # Find any pairings created in HA <= 0.84 + if os.path.exists(data_dir): + for device in os.listdir(data_dir): + if not device.startswith('hk-'): + continue + alias = device[3:] + if alias in old_pairings: + continue + with open(os.path.join(data_dir, device)) as pairing_data_fp: + old_pairings[alias] = json.load(pairing_data_fp) + + return old_pairings + + +@callback +def find_existing_host(hass, serial): + """Return a set of the configured hosts.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data['AccessoryPairingID'] == serial: + return entry + + +@config_entries.HANDLERS.register(DOMAIN) +class HomekitControllerFlowHandler(config_entries.ConfigFlow): + """Handle a HomeKit config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the homekit_controller flow.""" + self.model = None + self.hkid = None + self.devices = {} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + import homekit + + errors = {} + + if user_input is not None: + key = user_input['device'] + props = self.devices[key]['properties'] + self.hkid = props['id'] + self.model = props['md'] + return await self.async_step_pair() + + controller = homekit.Controller() + all_hosts = await self.hass.async_add_executor_job( + controller.discover, 5 + ) + + self.devices = {} + for host in all_hosts: + status_flags = int(host['properties']['sf']) + paired = not status_flags & 0x01 + if paired: + continue + self.devices[host['properties']['id']] = host + + if not self.devices: + return self.async_abort( + reason='no_devices' + ) + + return self.async_show_form( + step_id='user', + errors=errors, + data_schema=vol.Schema({ + vol.Required('device'): vol.In(self.devices.keys()), + }) + ) + + async def async_step_discovery(self, discovery_info): + """Handle a discovered HomeKit accessory. + + This flow is triggered by the discovery component. + """ + # Normalize properties from discovery + # homekit_python has code to do this, but not in a form we can + # easily use, so do the bare minimum ourselves here instead. + properties = { + key.lower(): value + for (key, value) in discovery_info['properties'].items() + } + + # The hkid is a unique random number that looks like a pairing code. + # It changes if a device is factory reset. + hkid = properties['id'] + model = properties['md'] + + status_flags = int(properties['sf']) + paired = not status_flags & 0x01 + + # The configuration number increases every time the characteristic map + # needs updating. Some devices use a slightly off-spec name so handle + # both cases. + try: + config_num = int(properties['c#']) + except KeyError: + _LOGGER.warning( + "HomeKit device %s: c# not exposed, in violation of spec", + hkid) + config_num = None + + if paired: + if hkid in self.hass.data.get(KNOWN_DEVICES, {}): + # The device is already paired and known to us + # According to spec we should monitor c# (config_num) for + # changes. If it changes, we check for new entities + conn = self.hass.data[KNOWN_DEVICES][hkid] + if conn.config_num != config_num: + _LOGGER.debug( + "HomeKit info %s: c# incremented, refreshing entities", + hkid) + self.hass.async_create_task( + conn.async_config_num_changed(config_num)) + return self.async_abort(reason='already_configured') + + old_pairings = await self.hass.async_add_executor_job( + load_old_pairings, + self.hass + ) + + if hkid in old_pairings: + return await self.async_import_legacy_pairing( + properties, + old_pairings[hkid] + ) + + # Device is paired but not to us - ignore it + _LOGGER.debug("HomeKit device %s ignored as already paired", hkid) + return self.async_abort(reason='already_paired') + + # Devices in HOMEKIT_IGNORE have native local integrations - users + # should be encouraged to use native integration and not confused + # by alternative HK API. + if model in HOMEKIT_IGNORE: + return self.async_abort(reason='ignored_model') + + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + self.model = model + self.hkid = hkid + return await self.async_step_pair() + + async def async_import_legacy_pairing(self, discovery_props, pairing_data): + """Migrate a legacy pairing to config entries.""" + from homekit.controller.ip_implementation import IpPairing + + hkid = discovery_props['id'] + + existing = find_existing_host(self.hass, hkid) + if existing: + _LOGGER.info( + ("Legacy configuration for homekit accessory %s" + "not loaded as already migrated"), hkid) + return self.async_abort(reason='already_configured') + + _LOGGER.info( + ("Legacy configuration %s for homekit" + "accessory migrated to config entries"), hkid) + + pairing = IpPairing(pairing_data) + + return await self._entry_from_accessory(pairing) + + async def async_step_pair(self, pair_info=None): + """Pair with a new HomeKit accessory.""" + import homekit # pylint: disable=import-error + + errors = {} + + if pair_info: + code = pair_info['pairing_code'] + controller = homekit.Controller() + try: + await self.hass.async_add_executor_job( + controller.perform_pairing, self.hkid, self.hkid, code + ) + + pairing = controller.pairings.get(self.hkid) + if pairing: + return await self._entry_from_accessory( + pairing) + + errors['pairing_code'] = 'unable_to_pair' + except homekit.AuthenticationError: + errors['pairing_code'] = 'authentication_error' + except homekit.UnknownError: + errors['pairing_code'] = 'unknown_error' + except homekit.UnavailableError: + return self.async_abort(reason='already_paired') + + return self.async_show_form( + step_id='pair', + errors=errors, + data_schema=vol.Schema({ + vol.Required('pairing_code'): vol.All(str, vol.Strip), + }) + ) + + async def _entry_from_accessory(self, pairing): + """Return a config entry from an initialized bridge.""" + accessories = await self.hass.async_add_executor_job( + pairing.list_accessories_and_characteristics + ) + bridge_info = get_bridge_information(accessories) + name = get_accessory_name(bridge_info) + + return self.async_create_entry( + title=name, + data=pairing.pairing_data, + ) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py new file mode 100644 index 00000000000..5550846120b --- /dev/null +++ b/homeassistant/components/homekit_controller/connection.py @@ -0,0 +1,35 @@ +"""Helpers for managing a pairing with a HomeKit accessory or bridge.""" + + +def get_accessory_information(accessory): + """Obtain the accessory information service of a HomeKit device.""" + # pylint: disable=import-error + from homekit.model.services import ServicesTypes + from homekit.model.characteristics import CharacteristicsTypes + + result = {} + for service in accessory['services']: + stype = service['type'].upper() + if ServicesTypes.get_short(stype) != 'accessory-information': + continue + for characteristic in service['characteristics']: + ctype = CharacteristicsTypes.get_short(characteristic['type']) + if 'value' in characteristic: + result[ctype] = characteristic['value'] + return result + + +def get_bridge_information(accessories): + """Return the accessory info for the bridge.""" + for accessory in accessories: + if accessory['aid'] == 1: + return get_accessory_information(accessory) + return get_accessory_information(accessories[0]) + + +def get_accessory_name(accessory_info): + """Return the name field of an accessory.""" + for field in ('name', 'model', 'manufacturer'): + if field in accessory_info: + return accessory_info[field] + return None diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py new file mode 100644 index 00000000000..873f6b343d2 --- /dev/null +++ b/homeassistant/components/homekit_controller/const.py @@ -0,0 +1,23 @@ +"""Constants for the homekit_controller component.""" +DOMAIN = 'homekit_controller' + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) +CONTROLLER = "{}-controller".format(DOMAIN) + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', + 'switch': 'switch', + 'thermostat': 'climate', + 'security-system': 'alarm_control_panel', + 'garage-door-opener': 'cover', + 'window': 'cover', + 'window-covering': 'cover', + 'lock-mechanism': 'lock', + 'motion': 'binary_sensor', + 'humidity': 'sensor', + 'light': 'sensor', + 'temperature': 'sensor' +} diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 3951cf577d4..ccb1939e141 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -114,20 +114,20 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): """Return if the cover is opening or not.""" return self._state == STATE_OPENING - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Send open command.""" - self.set_door_state(STATE_OPEN) + await self.set_door_state(STATE_OPEN) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Send close command.""" - self.set_door_state(STATE_CLOSED) + await self.set_door_state(STATE_CLOSED) - def set_door_state(self, state): + async def set_door_state(self, state): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['door-state.target'], 'value': TARGET_GARAGE_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): @@ -232,41 +232,41 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): """Return if the cover is opening or not.""" return self._state == STATE_OPENING - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Send open command.""" - self.set_cover_position(position=100) + await self.async_set_cover_position(position=100) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Send close command.""" - self.set_cover_position(position=0) + await self.async_set_cover_position(position=0) - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Send position command.""" position = kwargs[ATTR_POSITION] characteristics = [{'aid': self._aid, 'iid': self._chars['position.target'], 'value': position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._tilt_position - def set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] if 'vertical-tilt.target' in self._chars: characteristics = [{'aid': self._aid, 'iid': self._chars['vertical-tilt.target'], 'value': tilt_position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) elif 'horizontal-tilt.target' in self._chars: characteristics = [{'aid': self._aid, 'iid': self._chars['horizontal-tilt.target'], 'value': tilt_position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index f39e793c184..b5677c0e095 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -25,11 +25,11 @@ class HomeKitLight(HomeKitEntity, 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 + self._on = False + self._brightness = 0 + self._color_temperature = 0 + self._hue = 0 + self._saturation = 0 def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" @@ -78,30 +78,24 @@ class HomeKitLight(HomeKitEntity, Light): @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 + return self._brightness * 255 / 100 @property def hs_color(self): """Return the color property.""" - if self._features & SUPPORT_COLOR: - return (self._hue, self._saturation) - return None + return (self._hue, self._saturation) @property def color_temp(self): """Return the color temperature.""" - if self._features & SUPPORT_COLOR_TEMP: - return self._color_temperature - return None + return self._color_temperature @property def supported_features(self): """Flag supported features.""" return self._features - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified light on.""" hs_color = kwargs.get(ATTR_HS_COLOR) temperature = kwargs.get(ATTR_COLOR_TEMP) @@ -127,11 +121,11 @@ class HomeKitLight(HomeKitEntity, Light): characteristics.append({'aid': self._aid, 'iid': self._chars['on'], 'value': True}) - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified light off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 635d457198a..6da5fa35655 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -75,20 +75,20 @@ class HomeKitLock(HomeKitEntity, LockDevice): """Return True if entity is available.""" return self._state is not None - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the device.""" - self._set_lock_state(STATE_LOCKED) + await self._set_lock_state(STATE_LOCKED) - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._set_lock_state(STATE_UNLOCKED) + await self._set_lock_state(STATE_UNLOCKED) - def _set_lock_state(self, state): + async def _set_lock_state(self, state): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['lock-mechanism.target-state'], 'value': TARGET_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py new file mode 100644 index 00000000000..5af0016eb16 --- /dev/null +++ b/homeassistant/components/homekit_controller/sensor.py @@ -0,0 +1,153 @@ +"""Support for Homekit sensors.""" +from homeassistant.components.homekit_controller import ( + KNOWN_ACCESSORIES, HomeKitEntity) +from homeassistant.const import TEMP_CELSIUS + +DEPENDENCIES = ['homekit_controller'] + +HUMIDITY_ICON = 'mdi-water-percent' +TEMP_C_ICON = "mdi-temperature-celsius" +BRIGHTNESS_ICON = "mdi-brightness-6" + +UNIT_PERCENT = "%" +UNIT_LUX = "lux" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Homekit sensor support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + devtype = discovery_info['device-type'] + + if devtype == 'humidity': + add_entities( + [HomeKitHumiditySensor(accessory, discovery_info)], True) + elif devtype == 'temperature': + add_entities( + [HomeKitTemperatureSensor(accessory, discovery_info)], True) + elif devtype == 'light': + add_entities( + [HomeKitLightSensor(accessory, discovery_info)], True) + + +class HomeKitHumiditySensor(HomeKitEntity): + """Representation of a Homekit humidity sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Humidity") + + @property + def icon(self): + """Return the sensor icon.""" + return HUMIDITY_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_PERCENT + + def _update_relative_humidity_current(self, value): + self._state = value + + @property + def state(self): + """Return the current humidity.""" + return self._state + + +class HomeKitTemperatureSensor(HomeKitEntity): + """Representation of a Homekit temperature sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.TEMPERATURE_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Temperature") + + @property + def icon(self): + """Return the sensor icon.""" + return TEMP_C_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return TEMP_CELSIUS + + def _update_temperature_current(self, value): + self._state = value + + @property + def state(self): + """Return the current temperature in Celsius.""" + return self._state + + +class HomeKitLightSensor(HomeKitEntity): + """Representation of a Homekit light level sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.LIGHT_LEVEL_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Light Level") + + @property + def icon(self): + """Return the sensor icon.""" + return BRIGHTNESS_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_LUX + + def _update_light_level_current(self, value): + self._state = value + + @property + def state(self): + """Return the current light level in lux.""" + return self._state diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json new file mode 100644 index 00000000000..b1601a1f33e --- /dev/null +++ b/homeassistant/components/homekit_controller/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "HomeKit Accessory", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with HomeKit Accessory", + "description": "Enter your HomeKit pairing code to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + } + } +} diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index daa4ede6898..21f10e6243c 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -48,20 +48,20 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): """Return true if device is on.""" return self._on - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified switch on.""" self._on = True characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': True}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified switch off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index dba4add216d..a8109af5ed8 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.56'] +REQUIREMENTS = ['pyhomematic==0.1.58'] _LOGGER = logging.getLogger(__name__) @@ -74,18 +74,18 @@ HM_DEVICE_TYPES = { 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage', - 'UniversalSensor', 'MotionIPV2', 'IPMultiIO'], + 'UniversalSensor', 'MotionIPV2', 'IPMultiIO', 'IPThermostatWall2'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', - 'ThermostatGroup', 'IPThermostatWall230V'], + 'ThermostatGroup', 'IPThermostatWall230V', 'IPThermostatWall2'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', 'SmartwareMotion', 'IPWeatherSensorPlus', 'MotionIPV2', 'WaterIP', - 'IPMultiIO', 'TiltIP'], + 'IPMultiIO', 'TiltIP', 'IPShutterContactSabotage'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -98,7 +98,8 @@ HM_IGNORE_DISCOVERY_NODE = [ HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { 'ACTUAL_TEMPERATURE': [ 'IPAreaThermostat', 'IPWeatherSensor', - 'IPWeatherSensorPlus', 'IPWeatherSensorBasic'], + 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', + 'IPThermostatWall', 'IPThermostatWall2'], } HM_ATTRIBUTE_SUPPORT = { diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index c4d97dca3fe..8e3e55e1f7f 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -2,7 +2,7 @@ import logging from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, POWER_WATT, ENERGY_WATT_HOUR _LOGGER = logging.getLogger(__name__) @@ -24,10 +24,10 @@ HM_UNIT_HA_CAST = { 'TEMPERATURE': '°C', 'ACTUAL_TEMPERATURE': '°C', 'BRIGHTNESS': '#', - 'POWER': 'W', + 'POWER': POWER_WATT, 'CURRENT': 'mA', 'VOLTAGE': 'V', - 'ENERGY_COUNTER': 'Wh', + 'ENERGY_COUNTER': ENERGY_WATT_HOUR, 'GAS_POWER': 'm3', 'GAS_ENERGY_COUNTER': 'm3', 'LUX': 'lx', diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index bd600f7d2ef..c2a7579e4fc 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -18,7 +18,7 @@ "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", "pin": "PIN Code (optional)" }, - "title": "HometicIP Accesspoint ausw\u00e4hlen" + "title": "HomematicIP Accesspoint ausw\u00e4hlen" }, "link": { "description": "Dr\u00fccke den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 6f785565661..ac93ef05b85 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -15,7 +15,7 @@ from .const import ( from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 -REQUIREMENTS = ['homematicip==0.10.5'] +REQUIREMENTS = ['homematicip==0.10.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d6ce4152001..9445d6521cc 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -18,6 +18,7 @@ ATTR_WINDOWSTATE = 'window state' ATTR_MOISTUREDETECTED = 'moisture detected' ATTR_WATERLEVELDETECTED = 'water level detected' ATTR_SMOKEDETECTORALARM = 'smoke detector alarm' +ATTR_TODAY_SUNSHINE_DURATION = 'today_sunshine_duration_in_minutes' async def async_setup_platform( @@ -31,7 +32,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): from homematicip.aio.device import ( AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector, AsyncWaterSensor, AsyncRotaryHandleSensor, - AsyncMotionDetectorPushButton) + AsyncMotionDetectorPushButton, AsyncWeatherSensor, + AsyncWeatherSensorPlus, AsyncWeatherSensorPro) from homematicip.aio.group import ( AsyncSecurityGroup, AsyncSecurityZoneGroup) @@ -41,13 +43,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in home.devices: if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)): devices.append(HomematicipShutterContact(home, device)) - elif isinstance(device, (AsyncMotionDetectorIndoor, - AsyncMotionDetectorPushButton)): + if isinstance(device, (AsyncMotionDetectorIndoor, + AsyncMotionDetectorPushButton)): devices.append(HomematicipMotionDetector(home, device)) - elif isinstance(device, AsyncSmokeDetector): + if isinstance(device, AsyncSmokeDetector): devices.append(HomematicipSmokeDetector(home, device)) - elif isinstance(device, AsyncWaterSensor): + if isinstance(device, AsyncWaterSensor): devices.append(HomematicipWaterDetector(home, device)) + if isinstance(device, (AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipRainSensor(home, device)) + if isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipStormSensor(home, device)) + devices.append(HomematicipSunshineSensor(home, device)) for group in home.groups: if isinstance(group, AsyncSecurityGroup): @@ -121,10 +130,74 @@ class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): @property def is_on(self): - """Return true if moisture or waterlevel is detected.""" + """Return true, if moisture or waterlevel is detected.""" return self._device.moistureDetected or self._device.waterlevelDetected +class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud storm sensor.""" + + def __init__(self, home, device): + """Initialize storm sensor.""" + super().__init__(home, device, "Storm") + + @property + def icon(self): + """Return the icon.""" + return 'mdi:weather-windy' if self.is_on else 'mdi:pinwheel-outline' + + @property + def is_on(self): + """Return true, if storm is detected.""" + return self._device.storm + + +class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud rain sensor.""" + + def __init__(self, home, device): + """Initialize rain sensor.""" + super().__init__(home, device, "Raining") + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'moisture' + + @property + def is_on(self): + """Return true, if it is raining.""" + return self._device.raining + + +class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud sunshine sensor.""" + + def __init__(self, home, device): + """Initialize sunshine sensor.""" + super().__init__(home, device, 'Sunshine') + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'light' + + @property + def is_on(self): + """Return true if sun is shining.""" + return self._device.sunshine + + @property + def device_state_attributes(self): + """Return the state attributes of the illuminance sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'todaySunshineDuration') and \ + self._device.todaySunshineDuration: + attr[ATTR_TODAY_SUNSHINE_DURATION] = \ + self._device.todaySunshineDuration + return attr + + class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud security zone group.""" diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 06864d50ad1..fbda56f2805 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -13,6 +13,7 @@ COMPONENTS = [ 'light', 'sensor', 'switch', + 'weather', ] CONF_ACCESSPOINT = 'accesspoint' diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index d755735e0e0..d6155998332 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,17 +5,17 @@ from homeassistant.components.homematicip_cloud import ( DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS) + POWER_WATT, TEMP_CELSIUS) _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['homematicip_cloud'] +ATTR_TEMPERATURE_OFFSET = 'temperature_offset' ATTR_VALVE_STATE = 'valve_state' ATTR_VALVE_POSITION = 'valve_position' -ATTR_TEMPERATURE = 'temperature' -ATTR_TEMPERATURE_OFFSET = 'temperature_offset' -ATTR_HUMIDITY = 'humidity' +ATTR_WIND_DIRECTION = 'wind_direction' +ATTR_WIND_DIRECTION_VARIATION = 'wind_direction_variation_in_degree' async def async_setup_platform( @@ -27,25 +27,34 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the HomematicIP Cloud sensors from a config entry.""" from homematicip.aio.device import ( - AsyncHeatingThermostat, AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncHeatingThermostat, AsyncHeatingThermostatCompact, + AsyncTemperatureHumiditySensorWithoutDisplay, AsyncTemperatureHumiditySensorDisplay, AsyncMotionDetectorIndoor, AsyncTemperatureHumiditySensorOutdoor, AsyncMotionDetectorPushButton, AsyncLightSensor, AsyncPlugableSwitchMeasuring, AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring) + AsyncFullFlushSwitchMeasuring, AsyncWeatherSensor, + AsyncWeatherSensorPlus, AsyncWeatherSensorPro) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] for device in home.devices: - if isinstance(device, AsyncHeatingThermostat): + if isinstance(device, (AsyncHeatingThermostat, + AsyncHeatingThermostatCompact)): devices.append(HomematicipHeatingThermostat(home, device)) if isinstance(device, (AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorOutdoor)): + AsyncTemperatureHumiditySensorOutdoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): devices.append(HomematicipTemperatureSensor(home, device)) devices.append(HomematicipHumiditySensor(home, device)) if isinstance(device, (AsyncMotionDetectorIndoor, - AsyncMotionDetectorPushButton)): + AsyncMotionDetectorPushButton, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): devices.append(HomematicipIlluminanceSensor(home, device)) if isinstance(device, AsyncLightSensor): devices.append(HomematicipLightSensor(home, device)) @@ -53,6 +62,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring)): devices.append(HomematicipPowerSensor(home, device)) + if isinstance(device, (AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipWindspeedSensor(home, device)) + if isinstance(device, (AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipTodayRainSensor(home, device)) if devices: async_add_entities(devices) @@ -175,6 +191,15 @@ class HomematicipTemperatureSensor(HomematicipGenericDevice): """Return the unit this state is expressed in.""" return TEMP_CELSIUS + @property + def device_state_attributes(self): + """Return the state attributes of the windspeed sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'temperatureOffset') and \ + self._device.temperatureOffset: + attr[ATTR_TEMPERATURE_OFFSET] = self._device.temperatureOffset + return attr + class HomematicipIlluminanceSensor(HomematicipGenericDevice): """Represenation of a HomematicIP Illuminance device.""" @@ -223,4 +248,89 @@ class HomematicipPowerSensor(HomematicipGenericDevice): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" + return POWER_WATT + + +class HomematicipWindspeedSensor(HomematicipGenericDevice): + """Represenation of a HomematicIP wind speed sensor.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Windspeed') + + @property + def state(self): + """Represenation of the HomematicIP wind speed value.""" + return self._device.windSpeed + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'km/h' + + @property + def device_state_attributes(self): + """Return the state attributes of the wind speed sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'windDirection') and \ + self._device.windDirection: + attr[ATTR_WIND_DIRECTION] = \ + _get_wind_direction(self._device.windDirection) + if hasattr(self._device, 'windDirectionVariation') and \ + self._device.windDirectionVariation: + attr[ATTR_WIND_DIRECTION_VARIATION] = \ + self._device.windDirectionVariation + return attr + + +class HomematicipTodayRainSensor(HomematicipGenericDevice): + """Represenation of a HomematicIP rain counter of a day sensor.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Today Rain') + + @property + def state(self): + """Represenation of the HomematicIP todays rain value.""" + return round(self._device.todayRainCounter, 2) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'mm' + + +def _get_wind_direction(wind_direction_degree): + """Convert wind direction degree to named direction.""" + if 11.25 <= wind_direction_degree < 33.75: + return 'NNE' + if 33.75 <= wind_direction_degree < 56.25: + return 'NE' + if 56.25 <= wind_direction_degree < 78.75: + return 'ENE' + if 78.75 <= wind_direction_degree < 101.25: + return 'E' + if 101.25 <= wind_direction_degree < 123.75: + return 'ESE' + if 123.75 <= wind_direction_degree < 146.25: + return 'SE' + if 146.25 <= wind_direction_degree < 168.75: + return 'SSE' + if 168.75 <= wind_direction_degree < 191.25: + return 'S' + if 191.25 <= wind_direction_degree < 213.75: + return 'SSW' + if 213.75 <= wind_direction_degree < 236.25: + return 'SW' + if 236.25 <= wind_direction_degree < 258.75: + return 'WSW' + if 258.75 <= wind_direction_degree < 281.25: return 'W' + if 281.25 <= wind_direction_degree < 303.75: + return 'WNW' + if 303.75 <= wind_direction_degree < 326.25: + return 'NW' + if 326.25 <= wind_direction_degree < 348.75: + return 'NNW' + return 'N' diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py new file mode 100644 index 00000000000..5a6261195da --- /dev/null +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -0,0 +1,93 @@ + +"""Support for HomematicIP Cloud weather devices.""" +import logging + +from homeassistant.components.homematicip_cloud import ( + DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) +from homeassistant.components.weather import WeatherEntity + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the HomematicIP Cloud weather sensor.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the HomematicIP weather sensor from a config entry.""" + from homematicip.aio.device import ( + AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro, + ) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, AsyncWeatherSensorPro): + devices.append(HomematicipWeatherSensorPro(home, device)) + elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + devices.append(HomematicipWeatherSensor(home, device)) + + if devices: + async_add_entities(devices) + + +class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud weather sensor plus & basic.""" + + def __init__(self, home, device): + """Initialize the weather sensor.""" + super().__init__(home, device) + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.label + + @property + def temperature(self): + """Return the platform temperature.""" + return self._device.actualTemperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + @property + def humidity(self): + """Return the humidity.""" + return self._device.humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._device.windSpeed + + @property + def attribution(self): + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self): + """Return the current condition.""" + if hasattr(self._device, "raining") and self._device.raining: + return 'rainy' + if self._device.storm: + return 'windy' + if self._device.sunshine: + return 'sunny' + return '' + + +class HomematicipWeatherSensorPro(HomematicipWeatherSensor): + """representation of a HomematicIP weather sensor pro.""" + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._device.windDirection diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 93afbc04396..0bcf3f85ff7 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -18,7 +18,12 @@ from homeassistant.util.logging import HideSensitiveDataFilter from .auth import setup_auth from .ban import setup_bans -from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa +from .const import ( # noqa + KEY_AUTHENTICATED, + KEY_HASS, + KEY_HASS_USER, + KEY_REAL_IP, +) from .cors import setup_cors from .real_ip import setup_real_ip from .static import CACHE_HEADERS, CachingStaticResource @@ -66,8 +71,22 @@ def trusted_networks_deprecated(value): return value +def api_password_deprecated(value): + """Warn user api_password config is deprecated.""" + if not value: + return value + + _LOGGER.warning( + "Configuring api_password via the http component has been" + " deprecated. Use the legacy api password auth provider instead." + " For instructions, see https://www.home-assistant.io/docs/" + "authentication/providers/#legacy-api-password") + return value + + HTTP_SCHEMA = vol.Schema({ - vol.Optional(CONF_API_PASSWORD): cv.string, + vol.Optional(CONF_API_PASSWORD): + vol.All(cv.string, api_password_deprecated), vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, @@ -98,12 +117,10 @@ class ApiConfig: """Configuration settings for API server.""" def __init__(self, host: str, port: Optional[int] = SERVER_PORT, - use_ssl: bool = False, - api_password: Optional[str] = None) -> None: + use_ssl: bool = False) -> None: """Initialize a new API config object.""" self.host = host self.port = port - self.api_password = api_password host = host.rstrip('/') if host.startswith(("http://", "https://")): @@ -133,7 +150,6 @@ async def async_setup(hass, config): cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, []) - trusted_networks = conf[CONF_TRUSTED_NETWORKS] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] ssl_profile = conf[CONF_SSL_PROFILE] @@ -146,14 +162,12 @@ async def async_setup(hass, config): hass, server_host=server_host, server_port=server_port, - api_password=api_password, ssl_certificate=ssl_certificate, ssl_peer_certificate=ssl_peer_certificate, ssl_key=ssl_key, cors_origins=cors_origins, use_x_forwarded_for=use_x_forwarded_for, trusted_proxies=trusted_proxies, - trusted_networks=trusted_networks, login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, ssl_profile=ssl_profile, @@ -183,8 +197,7 @@ async def async_setup(hass, config): host = hass_util.get_local_ip() port = server_port - hass.config.api = ApiConfig(host, port, ssl_certificate is not None, - api_password) + hass.config.api = ApiConfig(host, port, ssl_certificate is not None) return True @@ -192,13 +205,14 @@ async def async_setup(hass, config): class HomeAssistantHTTP: """HTTP server for Home Assistant.""" - def __init__(self, hass, api_password, + def __init__(self, hass, ssl_certificate, ssl_peer_certificate, ssl_key, server_host, server_port, cors_origins, - use_x_forwarded_for, trusted_proxies, trusted_networks, + use_x_forwarded_for, trusted_proxies, login_threshold, is_ban_enabled, ssl_profile): """Initialize the HTTP Home Assistant server.""" app = self.app = web.Application(middlewares=[]) + app[KEY_HASS] = hass # This order matters setup_real_ip(app, use_x_forwarded_for, trusted_proxies) @@ -206,34 +220,16 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.support_legacy: - _LOGGER.warning( - "legacy_api_password support has been enabled. If you don't " - "require it, remove the 'api_password' from your http config.") - - for prv in hass.auth.auth_providers: - if prv.type == 'trusted_networks': - # auth_provider.trusted_networks will override - # http.trusted_networks, http.trusted_networks will be - # removed from future release - trusted_networks = prv.trusted_networks - break - - setup_auth(app, trusted_networks, - api_password if hass.auth.support_legacy else None) + setup_auth(hass, app) setup_cors(app, cors_origins) - app['hass'] = hass - self.hass = hass - self.api_password = api_password self.ssl_certificate = ssl_certificate self.ssl_peer_certificate = ssl_peer_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port - self.trusted_networks = trusted_networks self.is_ban_enabled = is_ban_enabled self.ssl_profile = ssl_profile self._handler = None diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 312fc2164c3..4736ef12391 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,6 +1,5 @@ """Authentication for HTTP component.""" import base64 -import hmac import logging from aiohttp import hdrs @@ -13,7 +12,11 @@ from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.core import callback from homeassistant.util import dt as dt_util -from .const import KEY_AUTHENTICATED, KEY_REAL_IP +from .const import ( + KEY_AUTHENTICATED, + KEY_HASS_USER, + KEY_REAL_IP, +) _LOGGER = logging.getLogger(__name__) @@ -40,10 +43,125 @@ def async_sign_path(hass, refresh_token_id, path, expiration): @callback -def setup_auth(app, trusted_networks, api_password): +def setup_auth(hass, app): """Create auth middleware for the app.""" old_auth_warning = set() + support_legacy = hass.auth.support_legacy + if support_legacy: + _LOGGER.warning("legacy_api_password support has been enabled.") + + trusted_networks = [] + for prv in hass.auth.auth_providers: + if prv.type == 'trusted_networks': + trusted_networks += prv.trusted_networks + + async def async_validate_auth_header(request): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ + try: + auth_type, auth_val = \ + request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + except ValueError: + # If no space in authorization header + return False + + if auth_type == 'Bearer': + refresh_token = await hass.auth.async_validate_access_token( + auth_val) + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True + + if auth_type == 'Basic' and support_legacy: + decoded = base64.b64decode(auth_val).decode('utf-8') + try: + username, password = decoded.split(':', 1) + except ValueError: + # If no ':' in decoded + return False + + if username != 'homeassistant': + return False + + user = await legacy_api_password.async_validate_password( + hass, password) + if user is None: + return False + + request[KEY_HASS_USER] = user + _LOGGER.info( + 'Basic auth with api_password is going to deprecate,' + ' please use a bearer token to access %s from %s', + request.path, request[KEY_REAL_IP]) + old_auth_warning.add(request.path) + return True + + return False + + async def async_validate_signed_request(request): + """Validate a signed request.""" + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + return False + + signature = request.query.get(SIGN_QUERY_PARAM) + + if signature is None: + return False + + try: + claims = jwt.decode( + signature, + secret, + algorithms=['HS256'], + options={'verify_iss': False} + ) + except jwt.InvalidTokenError: + return False + + if claims['path'] != request.path: + return False + + refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) + + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True + + async def async_validate_trusted_networks(request): + """Test if request is from a trusted ip.""" + ip_addr = request[KEY_REAL_IP] + + if not any(ip_addr in trusted_network + for trusted_network in trusted_networks): + return False + + user = await hass.auth.async_get_owner() + if user is None: + return False + + request[KEY_HASS_USER] = user + return True + + async def async_validate_legacy_api_password(request, password): + """Validate api_password.""" + user = await legacy_api_password.async_validate_password( + hass, password) + if user is None: + return False + + request[KEY_HASS_USER] = user + return True + @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" @@ -53,13 +171,14 @@ def setup_auth(app, trusted_networks, api_password): DATA_API_PASSWORD in request.query): if request.path not in old_auth_warning: _LOGGER.log( - logging.INFO if api_password else logging.WARNING, - 'You need to use a bearer token to access %s from %s', + logging.INFO if support_legacy else logging.WARNING, + 'api_password is going to deprecate. You need to use a' + ' bearer token to access %s from %s', request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) if (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header(request, api_password)): + await async_validate_auth_header(request)): # it included both use_auth and api_password Basic auth authenticated = True @@ -69,133 +188,21 @@ def setup_auth(app, trusted_networks, api_password): await async_validate_signed_request(request)): authenticated = True - elif (api_password and HTTP_HEADER_HA_AUTH in request.headers and - hmac.compare_digest( - api_password.encode('utf-8'), - request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): - # A valid auth header has been set + elif (trusted_networks and + await async_validate_trusted_networks(request)): authenticated = True - request['hass_user'] = await legacy_api_password.async_get_user( - app['hass']) - elif (api_password and DATA_API_PASSWORD in request.query and - hmac.compare_digest( - api_password.encode('utf-8'), - request.query[DATA_API_PASSWORD].encode('utf-8'))): + elif (support_legacy and HTTP_HEADER_HA_AUTH in request.headers and + await async_validate_legacy_api_password( + request, request.headers[HTTP_HEADER_HA_AUTH])): authenticated = True - request['hass_user'] = await legacy_api_password.async_get_user( - app['hass']) - elif _is_trusted_ip(request, trusted_networks): - users = await app['hass'].auth.async_get_users() - for user in users: - if user.is_owner: - request['hass_user'] = user - authenticated = True - break + elif (support_legacy and DATA_API_PASSWORD in request.query and + await async_validate_legacy_api_password( + request, request.query[DATA_API_PASSWORD])): + authenticated = True request[KEY_AUTHENTICATED] = authenticated return await handler(request) app.middlewares.append(auth_middleware) - - -def _is_trusted_ip(request, trusted_networks): - """Test if request is from a trusted ip.""" - ip_addr = request[KEY_REAL_IP] - - return any( - ip_addr in trusted_network for trusted_network - in trusted_networks) - - -def validate_password(request, api_password): - """Test if password is valid.""" - return hmac.compare_digest( - api_password.encode('utf-8'), - request.app['hass'].http.api_password.encode('utf-8')) - - -async def async_validate_auth_header(request, api_password=None): - """ - Test authorization header against access token. - - Basic auth_type is legacy code, should be removed with api_password. - """ - if hdrs.AUTHORIZATION not in request.headers: - return False - - try: - auth_type, auth_val = \ - request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) - except ValueError: - # If no space in authorization header - return False - - hass = request.app['hass'] - - if auth_type == 'Bearer': - refresh_token = await hass.auth.async_validate_access_token(auth_val) - if refresh_token is None: - return False - - request['hass_refresh_token'] = refresh_token - request['hass_user'] = refresh_token.user - return True - - if auth_type == 'Basic' and api_password is not None: - decoded = base64.b64decode(auth_val).decode('utf-8') - try: - username, password = decoded.split(':', 1) - except ValueError: - # If no ':' in decoded - return False - - if username != 'homeassistant': - return False - - if not hmac.compare_digest(api_password.encode('utf-8'), - password.encode('utf-8')): - return False - - request['hass_user'] = await legacy_api_password.async_get_user(hass) - return True - - return False - - -async def async_validate_signed_request(request): - """Validate a signed request.""" - hass = request.app['hass'] - secret = hass.data.get(DATA_SIGN_SECRET) - - if secret is None: - return False - - signature = request.query.get(SIGN_QUERY_PARAM) - - if signature is None: - return False - - try: - claims = jwt.decode( - signature, - secret, - algorithms=['HS256'], - options={'verify_iss': False} - ) - except jwt.InvalidTokenError: - return False - - if claims['path'] != request.path: - return False - - refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) - - if refresh_token is None: - return False - - request['hass_refresh_token'] = refresh_token - request['hass_user'] = refresh_token.user - - return True diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index e5494e945c4..f26220e63d1 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,3 +1,5 @@ """HTTP specific constants.""" KEY_AUTHENTICATED = 'ha_authenticated' +KEY_HASS = 'hass' +KEY_HASS_USER = 'hass_user' KEY_REAL_IP = 'ha_real_ip' diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 6da3b0e51d7..1ef70b5e022 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,5 +1,5 @@ """Provide CORS support for the HTTP component.""" -from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN +from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION from homeassistant.const import ( HTTP_HEADER_HA_AUTH, HTTP_HEADER_X_REQUESTED_WITH) @@ -7,7 +7,7 @@ from homeassistant.core import callback ALLOWED_CORS_HEADERS = [ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] + HTTP_HEADER_HA_AUTH, AUTHORIZATION] @callback diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 9662f3e6c23..bb7f2c2fee2 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,7 +14,7 @@ from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback from homeassistant.helpers.json import JSONEncoder -from .const import KEY_AUTHENTICATED, KEY_REAL_IP +from .const import KEY_AUTHENTICATED, KEY_REAL_IP, KEY_HASS _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def request_handler_factory(view, handler): async def handle(request): """Handle incoming request.""" - if not request.app['hass'].is_running: + if not request.app[KEY_HASS].is_running: return web.Response(status=503) authenticated = request.get(KEY_AUTHENTICATED, False) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 2ff21c4d5a7..a462b1b3072 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) # https://github.com/quandyfactory/dicttoxml/issues/60 logging.getLogger('dicttoxml').setLevel(logging.WARNING) -REQUIREMENTS = ['huawei-lte-api==1.1.3'] +REQUIREMENTS = ['huawei-lte-api==1.1.5'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) diff --git a/homeassistant/components/ifttt/.translations/th.json b/homeassistant/components/ifttt/.translations/th.json new file mode 100644 index 00000000000..077956287b3 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/th.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 0a06947b00f..4ab361d41eb 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) EVENT_RECEIVED = 'ifttt_webhook_received' ATTR_EVENT = 'event' +ATTR_TARGET = 'target' ATTR_VALUE1 = 'value1' ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' @@ -29,6 +30,7 @@ SERVICE_TRIGGER = 'trigger' SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Required(ATTR_EVENT): cv.string, + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_VALUE1): cv.string, vol.Optional(ATTR_VALUE2): cv.string, vol.Optional(ATTR_VALUE3): cv.string, @@ -36,7 +38,7 @@ SERVICE_TRIGGER_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ vol.Optional(DOMAIN): vol.Schema({ - vol.Required(CONF_KEY): cv.string, + vol.Required(CONF_KEY): vol.Any({cv.string: cv.string}, cv.string), }), }, extra=vol.ALLOW_EXTRA) @@ -46,18 +48,32 @@ async def async_setup(hass, config): if DOMAIN not in config: return True - key = config[DOMAIN][CONF_KEY] + api_keys = config[DOMAIN][CONF_KEY] + if isinstance(api_keys, str): + api_keys = {"default": api_keys} def trigger_service(call): """Handle IFTTT trigger service calls.""" event = call.data[ATTR_EVENT] + targets = call.data.get(ATTR_TARGET, list(api_keys)) value1 = call.data.get(ATTR_VALUE1) value2 = call.data.get(ATTR_VALUE2) value3 = call.data.get(ATTR_VALUE3) + target_keys = dict() + for target in targets: + if target not in api_keys: + _LOGGER.error("No IFTTT api key for %s", target) + continue + target_keys[target] = api_keys[target] + try: import pyfttt - pyfttt.send_event(key, event, value1, value2, value3) + for target, key in target_keys.items(): + res = pyfttt.send_event(key, event, value1, value2, value3) + if res.status_code != 200: + _LOGGER.error("IFTTT reported error sending event to %s.", + target) except requests.exceptions.RequestException: _LOGGER.exception("Error communicating with IFTTT") diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index bd45a52944c..daaf471e318 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -7,9 +7,11 @@ import voluptuous as vol from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.components.ihc.const import ( ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, - CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_POSITION, - CONF_SENSOR, CONF_SWITCH, CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, - SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_OFF_ID, + CONF_ON_ID, CONF_POSITION, CONF_SENSOR, CONF_SWITCH, CONF_XPATH, + SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_FLOAT, + SERVICE_SET_RUNTIME_VALUE_INT, SERVICE_PULSE) +from homeassistant.components.ihc.util import async_pulse from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, @@ -50,7 +52,10 @@ DEVICE_SCHEMA = vol.Schema({ }) -SWITCH_SCHEMA = DEVICE_SCHEMA.extend({}) +SWITCH_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_OFF_ID, default=0): cv.positive_int, + vol.Optional(CONF_ON_ID, default=0): cv.positive_int, +}) BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ vol.Optional(CONF_INVERTING, default=False): cv.boolean, @@ -59,6 +64,8 @@ BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ LIGHT_SCHEMA = DEVICE_SCHEMA.extend({ vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, + vol.Optional(CONF_OFF_ID, default=0): cv.positive_int, + vol.Optional(CONF_ON_ID, default=0): cv.positive_int, }) SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ @@ -138,6 +145,10 @@ SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema({ vol.Required(ATTR_VALUE): vol.Coerce(float), }) +PULSE_SCHEMA = vol.Schema({ + vol.Required(ATTR_IHC_ID): cv.positive_int, +}) + def setup(hass, config): """Set up the IHC platform.""" @@ -197,6 +208,8 @@ def get_manual_configuration( 'product_cfg': { 'type': sensor_cfg.get(CONF_TYPE), 'inverting': sensor_cfg.get(CONF_INVERTING), + 'off_id': sensor_cfg.get(CONF_OFF_ID), + 'on_id': sensor_cfg.get(CONF_ON_ID), 'dimmable': sensor_cfg.get(CONF_DIMMABLE), 'unit_of_measurement': sensor_cfg.get( CONF_UNIT_OF_MEASUREMENT) @@ -287,6 +300,11 @@ def setup_service_functions(hass: HomeAssistantType, ihc_controller): value = call.data[ATTR_VALUE] ihc_controller.set_runtime_value_float(ihc_id, value) + async def async_pulse_runtime_input(call): + """Pulse a IHC controller input function.""" + ihc_id = call.data[ATTR_IHC_ID] + await async_pulse(hass, ihc_controller, ihc_id) + hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL, set_runtime_value_bool, schema=SET_RUNTIME_VALUE_BOOL_SCHEMA) @@ -296,3 +314,6 @@ def setup_service_functions(hass: HomeAssistantType, ihc_controller): hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) + hass.services.register(DOMAIN, SERVICE_PULSE, + async_pulse_runtime_input, + schema=PULSE_SCHEMA) diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index 69342c944ba..2199a8a156e 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -9,6 +9,8 @@ CONF_LIGHT = 'light' CONF_NAME = 'name' CONF_NODE = 'node' CONF_NOTE = 'note' +CONF_OFF_ID = 'off_id' +CONF_ON_ID = 'on_id' CONF_POSITION = 'position' CONF_SENSOR = 'sensor' CONF_SWITCH = 'switch' @@ -20,3 +22,4 @@ ATTR_VALUE = 'value' SERVICE_SET_RUNTIME_VALUE_BOOL = 'set_runtime_value_bool' SERVICE_SET_RUNTIME_VALUE_FLOAT = 'set_runtime_value_float' SERVICE_SET_RUNTIME_VALUE_INT = 'set_runtime_value_int' +SERVICE_PULSE = 'pulse' diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index 2590ea83222..646be7597d0 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -2,7 +2,10 @@ import logging from homeassistant.components.ihc import IHC_CONTROLLER, IHC_DATA, IHC_INFO -from homeassistant.components.ihc.const import CONF_DIMMABLE +from homeassistant.components.ihc.const import ( + CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID) +from homeassistant.components.ihc.util import ( + async_pulse, async_set_bool, async_set_int) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) @@ -26,9 +29,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ihc_key = IHC_DATA.format(ctrl_id) info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + ihc_off_id = product_cfg.get(CONF_OFF_ID) + ihc_on_id = product_cfg.get(CONF_ON_ID) dimmable = product_cfg[CONF_DIMMABLE] - light = IhcLight(ihc_controller, name, ihc_id, info, - dimmable, product) + light = IhcLight(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id, + info, dimmable, product) devices.append(light) add_entities(devices) @@ -41,10 +46,13 @@ class IhcLight(IHCDevice, Light): an on/off (boolean) resource """ - def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - dimmable=False, product=None) -> None: + def __init__(self, ihc_controller, name, ihc_id: int, ihc_off_id: int, + ihc_on_id: int, info: bool, dimmable=False, + product=None) -> None: """Initialize the light.""" super().__init__(ihc_controller, name, ihc_id, info, product) + self._ihc_off_id = ihc_off_id + self._ihc_on_id = ihc_on_id self._brightness = 0 self._dimmable = dimmable self._state = None @@ -66,7 +74,7 @@ class IhcLight(IHCDevice, Light): return SUPPORT_BRIGHTNESS return 0 - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -76,17 +84,28 @@ class IhcLight(IHCDevice, Light): brightness = 255 if self._dimmable: - self.ihc_controller.set_runtime_value_int( - self.ihc_id, int(brightness * 100 / 255)) + await async_set_int(self.hass, self.ihc_controller, + self.ihc_id, int(brightness * 100 / 255)) else: - self.ihc_controller.set_runtime_value_bool(self.ihc_id, True) + if self._ihc_on_id: + await async_pulse(self.hass, self.ihc_controller, + self._ihc_on_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, True) - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs): """Turn the light off.""" if self._dimmable: - self.ihc_controller.set_runtime_value_int(self.ihc_id, 0) + await async_set_int(self.hass, self.ihc_controller, + self.ihc_id, 0) else: - self.ihc_controller.set_runtime_value_bool(self.ihc_id, False) + if self._ihc_off_id: + await async_pulse(self.hass, self.ihc_controller, + self._ihc_off_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, False) def on_ihc_change(self, ihc_id, value): """Handle IHC notifications.""" diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml index 0c63b32a618..0a78c45d7b2 100644 --- a/homeassistant/components/ihc/services.yaml +++ b/homeassistant/components/ihc/services.yaml @@ -24,3 +24,8 @@ set_runtime_value_float: value: description: The float value to set. +pulse: + description: Pulses an input on the IHC controller. + fields: + ihc_id: + description: The integer IHC resource ID. \ No newline at end of file diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index bbab9d3e68c..d25b343446d 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -1,5 +1,7 @@ """Support for IHC switches.""" from homeassistant.components.ihc import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from homeassistant.components.ihc.const import CONF_OFF_ID, CONF_ON_ID +from homeassistant.components.ihc.util import async_pulse, async_set_bool from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.switch import SwitchDevice @@ -13,14 +15,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for name, device in discovery_info.items(): ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] product = device['product'] # Find controller that corresponds with device id ctrl_id = device['ctrl_id'] ihc_key = IHC_DATA.format(ctrl_id) info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + ihc_off_id = product_cfg.get(CONF_OFF_ID) + ihc_on_id = product_cfg.get(CONF_ON_ID) - switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) + switch = IHCSwitch(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id, + info, product) devices.append(switch) add_entities(devices) @@ -28,10 +34,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class IHCSwitch(IHCDevice, SwitchDevice): """Representation of an IHC switch.""" - def __init__(self, ihc_controller, name: str, ihc_id: int, - info: bool, product=None) -> None: + def __init__(self, ihc_controller, name: str, ihc_id: int, ihc_off_id: int, + ihc_on_id: int, info: bool, product=None) -> None: """Initialize the IHC switch.""" super().__init__(ihc_controller, name, ihc_id, product) + self._ihc_off_id = ihc_off_id + self._ihc_on_id = ihc_on_id self._state = False @property @@ -39,13 +47,21 @@ class IHCSwitch(IHCDevice, SwitchDevice): """Return true if switch is on.""" return self._state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - self.ihc_controller.set_runtime_value_bool(self.ihc_id, True) + if self._ihc_on_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_on_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self.ihc_controller.set_runtime_value_bool(self.ihc_id, False) + if self._ihc_off_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_off_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, False) def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" diff --git a/homeassistant/components/ihc/util.py b/homeassistant/components/ihc/util.py new file mode 100644 index 00000000000..a6780262f5e --- /dev/null +++ b/homeassistant/components/ihc/util.py @@ -0,0 +1,22 @@ +"""Useful functions for the IHC component.""" + +import asyncio + + +async def async_pulse(hass, ihc_controller, ihc_id: int): + """Send a short on/off pulse to an IHC controller resource.""" + await async_set_bool(hass, ihc_controller, ihc_id, True) + await asyncio.sleep(0.1) + await async_set_bool(hass, ihc_controller, ihc_id, False) + + +def async_set_bool(hass, ihc_controller, ihc_id: int, value: bool): + """Set a bool value on an IHC controller resource.""" + return hass.async_add_executor_job(ihc_controller.set_runtime_value_bool, + ihc_id, value) + + +def async_set_int(hass, ihc_controller, ihc_id: int, value: int): + """Set a int value on an IHC controller resource.""" + return hass.async_add_executor_job(ihc_controller.set_runtime_value_int, + ihc_id, value) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index f854384bb03..aa3b2db7369 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -75,7 +75,7 @@ async def async_setup(hass, config): async def async_scan_service(service): """Service handler for scan.""" - image_entities = component.async_extract_from_service(service) + image_entities = await component.async_extract_from_service(service) update_tasks = [] for entity in image_entities: diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 7cb5184b116..10173cdb725 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -1,9 +1,4 @@ -""" -Component that performs OpenCV classification on images. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/image_processing.opencv/ -""" +"""Support for OpenCV classification on images.""" from datetime import timedelta import logging @@ -16,7 +11,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.16.1'] +REQUIREMENTS = ['numpy==1.16.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index f0e8f5182fc..4e4a80a525e 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -12,7 +12,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.16.1', 'pillow==5.4.1', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.16.2', 'pillow==5.4.1', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "No OpenCV library found. TensorFlow will process image with " "PIL at reduced resolution") - # setup tensorflow graph, session, and label map to pass to processor + # Set up Tensorflow graph, session, and label map to pass to processor # pylint: disable=no-member detection_graph = tf.Graph() with detection_graph.as_default(): diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json index a311daa6f9e..3f6c634f38f 100644 --- a/homeassistant/components/ios/.translations/cs.json +++ b/homeassistant/components/ios/.translations/cs.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Povolena je pouze jedna instance Home Assistant iOS." + }, "step": { "confirm": { "description": "Chcete nastavit komponenty Home Assistant iOS?", diff --git a/homeassistant/components/ipma/.translations/es.json b/homeassistant/components/ipma/.translations/es.json index c364ca286e3..acb8b51a44c 100644 --- a/homeassistant/components/ipma/.translations/es.json +++ b/homeassistant/components/ipma/.translations/es.json @@ -10,6 +10,7 @@ "longitude": "Longitud", "name": "Nombre" }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", "title": "Ubicaci\u00f3n" } }, diff --git a/homeassistant/components/ipma/.translations/th.json b/homeassistant/components/ipma/.translations/th.json new file mode 100644 index 00000000000..0be7c037231 --- /dev/null +++ b/homeassistant/components/ipma/.translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "name_exists": "\u0e21\u0e35\u0e0a\u0e37\u0e48\u0e2d\u0e19\u0e35\u0e49\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27" + }, + "step": { + "user": { + "data": { + "name": "\u0e0a\u0e37\u0e48\u0e2d" + }, + "title": "\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 60212d081de..2115c19f496 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -5,7 +5,8 @@ from typing import Callable from homeassistant.components.isy994 import ( ISY994_NODES, ISY994_WEATHER, ISYDevice) from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX, POWER_WATT) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -75,7 +76,7 @@ UOM_FRIENDLY_NAME = { '69': 'gal', '71': UNIT_UV_INDEX, '72': 'V', - '73': 'W', + '73': POWER_WATT, '74': 'W/m²', '75': 'weekday', '76': 'Wind Direction (°)', diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index e3786627075..00b183fca46 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, POWER_WATT, ENERGY_WATT_HOUR from homeassistant.helpers.entity import Entity from homeassistant.components.juicenet import JuicenetDevice, DOMAIN @@ -14,9 +14,9 @@ SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_CELSIUS], 'voltage': ['Voltage', 'V'], 'amps': ['Amps', 'A'], - 'watts': ['Watts', 'W'], + 'watts': ['Watts', POWER_WATT], 'charge_time': ['Charge time', 's'], - 'energy_added': ['Energy added', 'Wh'] + 'energy_added': ['Energy added', ENERGY_WATT_HOUR] } diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fdaba5e5709..ea5b18b7ede 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.9.4'] +REQUIREMENTS = ['xknx==0.10.0'] _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,7 @@ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" CONF_KNX_STATE_UPDATER = "state_updater" +CONF_KNX_RATE_LIMIT = "rate_limit" CONF_KNX_EXPOSE = "expose" CONF_KNX_EXPOSE_TYPE = "type" CONF_KNX_EXPOSE_ADDRESS = "address" @@ -62,6 +63,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, + vol.Optional(CONF_KNX_RATE_LIMIT, default=20): + vol.All(vol.Coerce(int), vol.Range(min=1, max=100)), vol.Optional(CONF_KNX_EXPOSE): vol.All( cv.ensure_list, @@ -138,7 +141,8 @@ class KNXModule: def init_xknx(self): """Initialize of KNX object.""" from xknx import XKNX - self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop) + self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop, + rate_limit=self.config[DOMAIN][CONF_KNX_RATE_LIMIT]) async def start(self): """Start KNX object. Connect to tunneling or Routing device.""" diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 96b9f2ea91f..921b2936d97 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -17,6 +17,7 @@ CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max' CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_TARGET_TEMPERATURE_STATE_ADDRESS = 'target_temperature_state_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' @@ -57,7 +58,8 @@ OPERATION_MODES_INV = dict(( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_STEP, @@ -136,9 +138,11 @@ def async_add_entities_config(hass, config, async_add_entities): climate = xknx.devices.Climate( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), - group_address_temperature=config.get(CONF_TEMPERATURE_ADDRESS), + group_address_temperature=config[CONF_TEMPERATURE_ADDRESS], group_address_target_temperature=config.get( CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_target_temperature_state=config[ + CONF_TARGET_TEMPERATURE_STATE_ADDRESS], group_address_setpoint_shift=config.get(CONF_SETPOINT_SHIFT_ADDRESS), group_address_setpoint_shift_state=config.get( CONF_SETPOINT_SHIFT_STATE_ADDRESS), diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index e3f9a46743d..3a2dc3b2417 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -14,34 +14,25 @@ from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, - CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, - CONF_ACCESS_TOKEN, ATTR_ENTITY_ID, ATTR_STATE, STATE_ON) -from homeassistant.helpers.dispatcher import ( - async_dispatcher_send, dispatcher_send) + HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SENSORS, + CONF_SWITCHES, CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE, + CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, ATTR_ENTITY_ID, ATTR_STATE, + STATE_ON) +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv +from .const import ( + CONF_ACTIVATION, CONF_API_HOST, + CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, CONF_REPEAT, + CONF_INVERSE, CONF_BLINK, CONF_DISCOVERY, CONF_DHT_SENSORS, + CONF_DS18B20_SENSORS, DOMAIN, STATE_LOW, STATE_HIGH, PIN_TO_ZONE, + ZONE_TO_PIN, ENDPOINT_ROOT, UPDATE_ENDPOINT, SIGNAL_SENSOR_UPDATE) +from .handlers import HANDLERS + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['konnected==0.1.4'] - -DOMAIN = 'konnected' - -CONF_ACTIVATION = 'activation' -CONF_API_HOST = 'api_host' -CONF_MOMENTARY = 'momentary' -CONF_PAUSE = 'pause' -CONF_REPEAT = 'repeat' -CONF_INVERSE = 'inverse' -CONF_BLINK = 'blink' -CONF_DISCOVERY = 'discovery' - -STATE_LOW = 'low' -STATE_HIGH = 'high' - -PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6} -ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} +REQUIREMENTS = ['konnected==0.1.5'] _BINARY_SENSOR_SCHEMA = vol.All( vol.Schema({ @@ -53,6 +44,18 @@ _BINARY_SENSOR_SCHEMA = vol.All( }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) ) +_SENSOR_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), + vol.Required(CONF_TYPE): + vol.All(vol.Lower, vol.In(['dht', 'ds18b20'])), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLL_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), + }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) +) + _SWITCH_SCHEMA = vol.All( vol.Schema({ vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE), @@ -79,6 +82,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [_SENSOR_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( cv.ensure_list, [_SWITCH_SCHEMA]), vol.Optional(CONF_HOST): cv.string, @@ -93,10 +98,6 @@ CONFIG_SCHEMA = vol.Schema( DEPENDENCIES = ['http'] -ENDPOINT_ROOT = '/api/konnected' -UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') -SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' - async def async_setup(hass, config): """Set up the Konnected platform.""" @@ -180,30 +181,30 @@ class ConfiguredDevice: def save_data(self): """Save the device configuration to `hass.data`.""" - sensors = {} + binary_sensors = {} for entity in self.config.get(CONF_BINARY_SENSORS) or []: if CONF_ZONE in entity: pin = ZONE_TO_PIN[entity[CONF_ZONE]] else: pin = entity[CONF_PIN] - sensors[pin] = { + binary_sensors[pin] = { CONF_TYPE: entity[CONF_TYPE], CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( self.device_id[6:], PIN_TO_ZONE[pin])), CONF_INVERSE: entity.get(CONF_INVERSE), ATTR_STATE: None } - _LOGGER.debug('Set up sensor %s (initial state: %s)', - sensors[pin].get('name'), - sensors[pin].get(ATTR_STATE)) + _LOGGER.debug('Set up binary_sensor %s (initial state: %s)', + binary_sensors[pin].get('name'), + binary_sensors[pin].get(ATTR_STATE)) actuators = [] for entity in self.config.get(CONF_SWITCHES) or []: - if 'zone' in entity: - pin = ZONE_TO_PIN[entity['zone']] + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] else: - pin = entity['pin'] + pin = entity[CONF_PIN] act = { CONF_PIN: pin, @@ -216,10 +217,32 @@ class ConfiguredDevice: CONF_PAUSE: entity.get(CONF_PAUSE), CONF_REPEAT: entity.get(CONF_REPEAT)} actuators.append(act) - _LOGGER.debug('Set up actuator %s', act) + _LOGGER.debug('Set up switch %s', act) + + sensors = [] + for entity in self.config.get(CONF_SENSORS) or []: + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] + else: + pin = entity[CONF_PIN] + + sensor = { + CONF_PIN: pin, + CONF_NAME: entity.get( + CONF_NAME, 'Konnected {} Sensor {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + CONF_TYPE: entity[CONF_TYPE], + CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL) + } + sensors.append(sensor) + _LOGGER.debug('Set up %s sensor %s (initial state: %s)', + sensor.get(CONF_TYPE), + sensor.get(CONF_NAME), + sensor.get(ATTR_STATE)) device_data = { - CONF_BINARY_SENSORS: sensors, + CONF_BINARY_SENSORS: binary_sensors, + CONF_SENSORS: sensors, CONF_SWITCHES: actuators, CONF_BLINK: self.config.get(CONF_BLINK), CONF_DISCOVERY: self.config.get(CONF_DISCOVERY) @@ -232,12 +255,10 @@ class ConfiguredDevice: DOMAIN, CONF_DEVICES, self.device_id, device_data) self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data - discovery.load_platform( - self.hass, 'binary_sensor', DOMAIN, - {'device_id': self.device_id}, self.hass_config) - discovery.load_platform( - self.hass, 'switch', DOMAIN, - {'device_id': self.device_id}, self.hass_config) + for platform in ['binary_sensor', 'sensor', 'switch']: + discovery.load_platform( + self.hass, platform, DOMAIN, + {'device_id': self.device_id}, self.hass_config) class DiscoveredDevice: @@ -283,8 +304,8 @@ class DiscoveredDevice: """Return the configuration stored in `hass.data` for this device.""" return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) - def sensor_configuration(self): - """Return the configuration map for syncing sensors.""" + def binary_sensor_configuration(self): + """Return the configuration map for syncing binary sensors.""" return [{'pin': p} for p in self.stored_configuration[CONF_BINARY_SENSORS]] @@ -295,6 +316,19 @@ class DiscoveredDevice: else 1)} for data in self.stored_configuration[CONF_SWITCHES]] + def dht_sensor_configuration(self): + """Return the configuration map for syncing DHT sensors.""" + return [{CONF_PIN: sensor[CONF_PIN], + CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} for sensor + in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == 'dht'] + + def ds18b20_sensor_configuration(self): + """Return the configuration map for syncing DS18B20 sensors.""" + return [{'pin': sensor[CONF_PIN]} for sensor + in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == 'ds18b20'] + def update_initial_states(self): """Update the initial state of each sensor from status poll.""" for sensor_data in self.status.get('sensors'): @@ -311,57 +345,55 @@ class DiscoveredDevice: SIGNAL_SENSOR_UPDATE.format(entity_id), state) - def sync_device_config(self): - """Sync the new pin configuration to the Konnected device.""" - desired_sensor_configuration = self.sensor_configuration() - current_sensor_configuration = [ - {'pin': s[CONF_PIN]} for s in self.status.get('sensors')] - _LOGGER.debug('%s: desired sensor config: %s', self.device_id, - desired_sensor_configuration) - _LOGGER.debug('%s: current sensor config: %s', self.device_id, - current_sensor_configuration) - - desired_actuator_config = self.actuator_configuration() - current_actuator_config = self.status.get('actuators') - _LOGGER.debug('%s: desired actuator config: %s', self.device_id, - desired_actuator_config) - _LOGGER.debug('%s: current actuator config: %s', self.device_id, - current_actuator_config) - + def desired_settings_payload(self): + """Return a dict representing the desired device configuration.""" desired_api_host = \ self.hass.data[DOMAIN].get(CONF_API_HOST) or \ self.hass.config.api.base_url desired_api_endpoint = desired_api_host + ENDPOINT_ROOT - current_api_endpoint = self.status.get('endpoint') - _LOGGER.debug('%s: desired api endpoint: %s', self.device_id, - desired_api_endpoint) - _LOGGER.debug('%s: current api endpoint: %s', self.device_id, - current_api_endpoint) + return { + 'sensors': self.binary_sensor_configuration(), + 'actuators': self.actuator_configuration(), + 'dht_sensors': self.dht_sensor_configuration(), + 'ds18b20_sensors': self.ds18b20_sensor_configuration(), + 'auth_token': self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), + 'endpoint': desired_api_endpoint, + 'blink': self.stored_configuration.get(CONF_BLINK), + 'discovery': self.stored_configuration.get(CONF_DISCOVERY) + } - if (desired_sensor_configuration != current_sensor_configuration) or \ - (current_actuator_config != desired_actuator_config) or \ - (current_api_endpoint != desired_api_endpoint) or \ - (self.status.get(CONF_BLINK) != - self.stored_configuration.get(CONF_BLINK)) or \ - (self.status.get(CONF_DISCOVERY) != - self.stored_configuration.get(CONF_DISCOVERY)): + def current_settings_payload(self): + """Return a dict of configuration currently stored on the device.""" + settings = self.status['settings'] + if not settings: + settings = {} + + return { + 'sensors': [ + {'pin': s[CONF_PIN]} for s in self.status.get('sensors')], + 'actuators': self.status.get('actuators'), + 'dht_sensors': self.status.get(CONF_DHT_SENSORS), + 'ds18b20_sensors': self.status.get(CONF_DS18B20_SENSORS), + 'auth_token': settings.get('token'), + 'endpoint': settings.get('apiUrl'), + 'blink': settings.get(CONF_BLINK), + 'discovery': settings.get(CONF_DISCOVERY) + } + + def sync_device_config(self): + """Sync the new pin configuration to the Konnected device if needed.""" + _LOGGER.debug('Device %s settings payload: %s', self.device_id, + self.desired_settings_payload()) + if self.desired_settings_payload() != self.current_settings_payload(): _LOGGER.info('pushing settings to device %s', self.device_id) - self.client.put_settings( - desired_sensor_configuration, - desired_actuator_config, - self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), - desired_api_endpoint, - blink=self.stored_configuration.get(CONF_BLINK), - discovery=self.stored_configuration.get(CONF_DISCOVERY) - ) + self.client.put_settings(**self.desired_settings_payload()) class KonnectedView(HomeAssistantView): """View creates an endpoint to receive push updates from the device.""" url = UPDATE_ENDPOINT - extra_urls = [UPDATE_ENDPOINT + '/{pin_num}/{state}'] name = 'api:konnected' requires_auth = False # Uses access token from configuration @@ -406,8 +438,7 @@ class KonnectedView(HomeAssistantView): hass.states.get(pin[ATTR_ENTITY_ID]).state, pin[CONF_ACTIVATION])}) - async def put(self, request: Request, device_id, - pin_num=None, state=None) -> Response: + async def put(self, request: Request, device_id) -> Response: """Receive a sensor update via PUT request and async set state.""" hass = request.app['hass'] data = hass.data[DOMAIN] @@ -415,11 +446,10 @@ class KonnectedView(HomeAssistantView): try: # Konnected 2.2.0 and above supports JSON payloads payload = await request.json() pin_num = payload['pin'] - state = payload['state'] except json.decoder.JSONDecodeError: - _LOGGER.warning(("Your Konnected device software may be out of " - "date. Visit https://help.konnected.io for " - "updating instructions.")) + _LOGGER.error(("Your Konnected device software may be out of " + "date. Visit https://help.konnected.io for " + "updating instructions.")) auth = request.headers.get(AUTHORIZATION, None) if not hmac.compare_digest('Bearer {}'.format(self.auth_token), auth): @@ -430,20 +460,20 @@ class KonnectedView(HomeAssistantView): if device is None: return self.json_message('unregistered device', status_code=HTTP_BAD_REQUEST) - pin_data = device[CONF_BINARY_SENSORS].get(pin_num) + pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \ + next((s for s in device[CONF_SENSORS] if s[CONF_PIN] == pin_num), + None) if pin_data is None: return self.json_message('unregistered sensor/actuator', status_code=HTTP_BAD_REQUEST) - entity_id = pin_data.get(ATTR_ENTITY_ID) - if entity_id is None: - return self.json_message('uninitialized sensor/actuator', - status_code=HTTP_NOT_FOUND) - state = bool(int(state)) - if pin_data.get(CONF_INVERSE): - state = not state + pin_data['device_id'] = device_id + + for attr in ['state', 'temp', 'humi', 'addr']: + value = payload.get(attr) + handler = HANDLERS.get(attr) + if value is not None and handler: + hass.async_create_task(handler(hass, pin_data, payload)) - async_dispatcher_send( - hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) return self.json_message('ok') diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index cb15e44e798..a47f81b9556 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -39,9 +39,13 @@ class KonnectedBinarySensor(BinarySensorDevice): self._pin_num = pin_num self._state = self._data.get(ATTR_STATE) self._device_class = self._data.get(CONF_TYPE) - self._name = self._data.get(CONF_NAME, 'Konnected {} Zone {}'.format( - device_id, PIN_TO_ZONE[pin_num])) - _LOGGER.debug("Created new Konnected sensor: %s", self._name) + self._unique_id = '{}-{}'.format(device_id, PIN_TO_ZONE[pin_num]) + self._name = self._data.get(CONF_NAME) + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id @property def name(self): diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py new file mode 100644 index 00000000000..88293adfc81 --- /dev/null +++ b/homeassistant/components/konnected/const.py @@ -0,0 +1,27 @@ +"""Konnected constants.""" + +DOMAIN = 'konnected' + +CONF_ACTIVATION = 'activation' +CONF_API_HOST = 'api_host' +CONF_MOMENTARY = 'momentary' +CONF_PAUSE = 'pause' +CONF_POLL_INTERVAL = 'poll_interval' +CONF_PRECISION = 'precision' +CONF_REPEAT = 'repeat' +CONF_INVERSE = 'inverse' +CONF_BLINK = 'blink' +CONF_DISCOVERY = 'discovery' +CONF_DHT_SENSORS = 'dht_sensors' +CONF_DS18B20_SENSORS = 'ds18b20_sensors' + +STATE_LOW = 'low' +STATE_HIGH = 'high' + +PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6} +ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} + +ENDPOINT_ROOT = '/api/konnected' +UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') +SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' +SIGNAL_DS18B20_NEW = 'konnected.ds18b20.new' diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py new file mode 100644 index 00000000000..6e92e7f20c8 --- /dev/null +++ b/homeassistant/components/konnected/handlers.py @@ -0,0 +1,62 @@ +"""Handle Konnected messages.""" +import logging + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import decorator +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_STATE, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + +from .const import (CONF_INVERSE, SIGNAL_SENSOR_UPDATE, SIGNAL_DS18B20_NEW) + +_LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + + +@HANDLERS.register('state') +async def async_handle_state_update(hass, context, msg): + """Handle a binary sensor state update.""" + _LOGGER.debug("[state handler] context: %s msg: %s", context, msg) + entity_id = context.get(ATTR_ENTITY_ID) + state = bool(int(msg.get(ATTR_STATE))) + if msg.get(CONF_INVERSE): + state = not state + + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) + + +@HANDLERS.register('temp') +async def async_handle_temp_update(hass, context, msg): + """Handle a temperature sensor state update.""" + _LOGGER.debug("[temp handler] context: %s msg: %s", context, msg) + entity_id, temp = context.get(DEVICE_CLASS_TEMPERATURE), msg.get('temp') + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + + +@HANDLERS.register('humi') +async def async_handle_humi_update(hass, context, msg): + """Handle a humidity sensor state update.""" + _LOGGER.debug("[humi handler] context: %s msg: %s", context, msg) + entity_id, humi = context.get(DEVICE_CLASS_HUMIDITY), msg.get('humi') + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), humi) + + +@HANDLERS.register('addr') +async def async_handle_addr_update(hass, context, msg): + """Handle an addressable sensor update.""" + _LOGGER.debug("[addr handler] context: %s msg: %s", context, msg) + addr, temp = msg.get('addr'), msg.get('temp') + entity_id = context.get(addr) + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + else: + msg['device_id'] = context.get('device_id') + msg['temperature'] = temp + msg['addr'] = addr + async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg) diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py new file mode 100644 index 00000000000..eb3f5511346 --- /dev/null +++ b/homeassistant/components/konnected/sensor.py @@ -0,0 +1,124 @@ +"""Support for DHT and DS18B20 sensors attached to a Konnected device.""" +import logging + +from homeassistant.components.konnected.const import ( + DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE) +from homeassistant.const import ( + CONF_DEVICES, CONF_PIN, CONF_TYPE, CONF_NAME, CONF_SENSORS, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + +SENSOR_TYPES = { + DEVICE_CLASS_TEMPERATURE: ['Temperature', TEMP_CELSIUS], + DEVICE_CLASS_HUMIDITY: ['Humidity', '%'] +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up sensors attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[KONNECTED_DOMAIN] + device_id = discovery_info['device_id'] + sensors = [] + + # Initialize all DHT sensors. + dht_sensors = [sensor for sensor + in data[CONF_DEVICES][device_id][CONF_SENSORS] + if sensor[CONF_TYPE] == 'dht'] + for sensor in dht_sensors: + sensors.append( + KonnectedSensor(device_id, sensor, DEVICE_CLASS_TEMPERATURE)) + sensors.append( + KonnectedSensor(device_id, sensor, DEVICE_CLASS_HUMIDITY)) + + async_add_entities(sensors) + + @callback + def async_add_ds18b20(attrs): + """Add new KonnectedSensor representing a ds18b20 sensor.""" + sensor_config = next((s for s + in data[CONF_DEVICES][device_id][CONF_SENSORS] + if s[CONF_TYPE] == 'ds18b20' + and s[CONF_PIN] == attrs.get(CONF_PIN)), None) + + async_add_entities([ + KonnectedSensor(device_id, sensor_config, DEVICE_CLASS_TEMPERATURE, + addr=attrs.get('addr'), + initial_state=attrs.get('temp')) + ], True) + + # DS18B20 sensors entities are initialized when they report for the first + # time. Set up a listener for that signal from the Konnected component. + async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20) + + +class KonnectedSensor(Entity): + """Represents a Konnected DHT Sensor.""" + + def __init__(self, device_id, data, sensor_type, addr=None, + initial_state=None): + """Initialize the entity for a single sensor_type.""" + self._addr = addr + self._data = data + self._device_id = device_id + self._type = sensor_type + self._pin_num = self._data.get(CONF_PIN) + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._unique_id = addr or '{}-{}-{}'.format( + device_id, self._pin_num, sensor_type) + + # set initial state if known at initialization + self._state = initial_state + if self._state: + self._state = round(float(self._state), 1) + + # set entity name if given + self._name = self._data.get(CONF_NAME) + if self._name: + self._name += ' ' + SENSOR_TYPES[sensor_type][0] + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + async def async_added_to_hass(self): + """Store entity_id and register state change callback.""" + entity_id_key = self._addr or self._type + self._data[entity_id_key] = self.entity_id + async_dispatcher_connect( + self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), + self.async_set_state) + + @callback + def async_set_state(self, state): + """Update the sensor's state.""" + if self._type == DEVICE_CLASS_HUMIDITY: + self._state = int(float(state)) + else: + self._state = round(float(state), 1) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 897933e6d80..1a4b495297e 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -6,7 +6,7 @@ from homeassistant.components.konnected import ( CONF_PAUSE, CONF_REPEAT, STATE_LOW, STATE_HIGH) from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import ( - CONF_DEVICES, CONF_SWITCHES, CONF_PIN, ATTR_STATE) + ATTR_STATE, CONF_DEVICES, CONF_NAME, CONF_PIN, CONF_SWITCHES) _LOGGER = logging.getLogger(__name__) @@ -40,10 +40,13 @@ class KonnectedSwitch(ToggleEntity): self._pause = self._data.get(CONF_PAUSE) self._repeat = self._data.get(CONF_REPEAT) self._state = self._boolean_state(self._data.get(ATTR_STATE)) - self._name = self._data.get( - 'name', 'Konnected {} Actuator {}'.format( - device_id, PIN_TO_ZONE[pin_num])) - _LOGGER.debug("Created new switch: %s", self._name) + self._unique_id = '{}-{}'.format(device_id, PIN_TO_ZONE[pin_num]) + self._name = self._data.get(CONF_NAME) + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id @property def name(self): diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index e5e6a5bd522..9903676d9f9 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -24,7 +24,7 @@ CONF_PRIORITY = 'priority' DEPENDENCIES = ['lametric'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ICON, default='i555'): cv.string, + vol.Optional(CONF_ICON, default='a7956'): cv.string, vol.Optional(CONF_LIFETIME, default=10): cv.positive_int, vol.Optional(CONF_CYCLES, default=1): cv.positive_int, vol.Optional(CONF_PRIORITY, default='warning'): diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index c0b6158f186..19a9f7583ec 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -24,7 +24,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -250,7 +250,7 @@ class LIFXManager: async def service_handler(service): """Apply a service.""" tasks = [] - for light in self.service_to_entities(service): + for light in await self.async_service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: task = light.set_state(**service.data) tasks.append(self.hass.async_create_task(task)) @@ -265,7 +265,7 @@ class LIFXManager: """Register the LIFX effects as hass service calls.""" async def service_handler(service): """Apply a service, i.e. start an effect.""" - entities = self.service_to_entities(service) + entities = await self.async_service_to_entities(service) if entities: await self.start_effect( entities, service.service, **service.data) @@ -314,9 +314,9 @@ class LIFXManager: elif service == SERVICE_EFFECT_STOP: await self.effects_conductor.stop(bulbs) - def service_to_entities(self, service): + async def async_service_to_entities(self, service): """Return the known entities that a service call mentions.""" - entity_ids = extract_entity_ids(self.hass, service) + entity_ids = await async_extract_entity_ids(self.hass, service) if entity_ids: entities = [entity for entity in self.entities.values() if entity.entity_id in entity_ids] diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 93d7a67c6f0..acf95a3c081 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -123,10 +123,7 @@ LIGHT_TURN_OFF_SCHEMA = vol.Schema({ ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]), }) -LIGHT_TOGGLE_SCHEMA = vol.Schema({ - ATTR_ENTITY_ID: cv.comp_entity_ids, - ATTR_TRANSITION: VALID_TRANSITION, -}) +LIGHT_TOGGLE_SCHEMA = LIGHT_TURN_ON_SCHEMA PROFILE_SCHEMA = vol.Schema( vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) @@ -256,7 +253,7 @@ async def async_setup(hass, config): params = service.data.copy() # Convert the entity ids to valid light ids - target_lights = component.async_extract_from_service(service) + target_lights = await component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) if service.context.user_id: diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index bfbb98ad57e..17c288da6c2 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/light.flux_led/ import logging import socket import random +from asyncio import sleep +from functools import partial import voluptuous as vol @@ -170,6 +172,8 @@ class FluxLight(Light): self._custom_effect = device[CONF_CUSTOM_EFFECT] self._bulb = None self._error_reported = False + self._color = (0, 0, 100) + self._white_value = 0 def _connect(self): """Connect to Flux light.""" @@ -210,14 +214,14 @@ class FluxLight(Light): def brightness(self): """Return the brightness of this light between 0..255.""" if self._mode == MODE_WHITE: - return self.white_value + return self._white_value - return self._bulb.brightness + return int(self._color[2] / 100 * 255) @property def hs_color(self): """Return the color property.""" - return color_util.color_RGB_to_hs(*self._bulb.getRgb()) + return self._color[0:2] @property def supported_features(self): @@ -233,7 +237,7 @@ class FluxLight(Light): @property def white_value(self): """Return the white value of this light between 0..255.""" - return self._bulb.getRgbw()[3] + return self._white_value @property def effect_list(self): @@ -257,24 +261,25 @@ class FluxLight(Light): return None - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): + """Turn the specified or all lights on and wait for state.""" + await self.hass.async_add_executor_job(partial(self._turn_on, + **kwargs)) + # The bulb needs a second to tell its new values, + # so we wait 2 seconds before updating + await sleep(2) + + def _turn_on(self, **kwargs): """Turn the specified or all lights on.""" - if not self.is_on: - self._bulb.turnOn() + self._bulb.turnOn() hs_color = kwargs.get(ATTR_HS_COLOR) - - if hs_color: - rgb = color_util.color_hs_to_RGB(*hs_color) - else: - rgb = None - brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) white = kwargs.get(ATTR_WHITE_VALUE) # Show warning if effect set with rgb, brightness, or white level - if effect and (brightness or white or rgb): + if effect and (brightness or white or hs_color): _LOGGER.warning("RGB, brightness and white level are ignored when" " an effect is specified for a flux bulb") @@ -302,12 +307,11 @@ class FluxLight(Light): if brightness is None: brightness = self.brightness - # Preserve color on brightness/white level change - if rgb is None: - rgb = self._bulb.getRgb() - - if white is None and self._mode == MODE_RGBW: - white = self.white_value + if hs_color: + self._color = (hs_color[0], hs_color[1], brightness / 255 * 100) + elif brightness and (hs_color is None) and self._mode != MODE_WHITE: + self._color = (self._color[0], self._color[1], + brightness / 255 * 100) # handle W only mode (use brightness instead of white value) if self._mode == MODE_WHITE: @@ -315,11 +319,14 @@ class FluxLight(Light): # handle RGBW mode elif self._mode == MODE_RGBW: - self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) - + if white is None: + self._bulb.setRgbw(*color_util.color_hsv_to_RGB(*self._color)) + else: + self._bulb.setRgbw(w=white) # handle RGB mode else: - self._bulb.setRgb(*tuple(rgb), brightness=brightness) + self._bulb.setRgb(*color_util.color_hsv_to_RGB(*self._color)) + return def turn_off(self, **kwargs): """Turn the specified or all lights off.""" @@ -331,6 +338,10 @@ class FluxLight(Light): try: self._connect() self._error_reported = False + if self._bulb.getRgb() != (0, 0, 0): + color = self._bulb.getRgbw() + self._color = color_util.color_RGB_to_hsv(*color[0:3]) + self._white_value = color[3] except socket.error: self._disconnect() if not self._error_reported: @@ -338,5 +349,8 @@ class FluxLight(Light): self._ipaddr, self._name) self._error_reported = True return - self._bulb.update_state(retry=2) + if self._bulb.getRgb() != (0, 0, 0): + color = self._bulb.getRgbw() + self._color = color_util.color_RGB_to_hsv(*color[0:3]) + self._white_value = color[3] diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index 8060bef0fa8..f9b8dcd203b 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -1,9 +1,4 @@ -""" -Support for myStrom Wifi bulbs. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mystrom/ -""" +"""Support for myStrom Wifi bulbs.""" import logging import voluptuous as vol @@ -15,7 +10,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -REQUIREMENTS = ['python-mystrom==0.4.4'] +REQUIREMENTS = ['python-mystrom==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/nanoleaf_aurora.py b/homeassistant/components/light/nanoleaf.py similarity index 71% rename from homeassistant/components/light/nanoleaf_aurora.py rename to homeassistant/components/light/nanoleaf.py index 6d9c1a50f79..bd34c535b70 100644 --- a/homeassistant/components/light/nanoleaf_aurora.py +++ b/homeassistant/components/light/nanoleaf.py @@ -1,8 +1,8 @@ """ -Support for Nanoleaf Aurora platform. +Support for Nanoleaf Lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.nanoleaf_aurora/ +https://home-assistant.io/components/light.nanoleaf/ """ import logging @@ -19,20 +19,20 @@ from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['nanoleaf==0.4.1'] +REQUIREMENTS = ['pynanoleaf==0.0.5'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Aurora' +DEFAULT_NAME = 'Nanoleaf' -DATA_NANOLEAF_AURORA = 'nanoleaf_aurora' +DATA_NANOLEAF = 'nanoleaf' -CONFIG_FILE = '.nanoleaf_aurora.conf' +CONFIG_FILE = '.nanoleaf.conf' ICON = 'mdi:triangle-outline' -SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | - SUPPORT_COLOR) +SUPPORT_NANOLEAF = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | + SUPPORT_COLOR) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -42,20 +42,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nanoleaf Aurora device.""" - import nanoleaf - import nanoleaf.setup - if DATA_NANOLEAF_AURORA not in hass.data: - hass.data[DATA_NANOLEAF_AURORA] = dict() + """Set up the Nanoleaf light.""" + from pynanoleaf import Nanoleaf, Unavailable + if DATA_NANOLEAF not in hass.data: + hass.data[DATA_NANOLEAF] = dict() token = '' if discovery_info is not None: host = discovery_info['host'] name = discovery_info['hostname'] # if device already exists via config, skip discovery setup - if host in hass.data[DATA_NANOLEAF_AURORA]: + if host in hass.data[DATA_NANOLEAF]: return - _LOGGER.info("Discovered a new Aurora: %s", discovery_info) + _LOGGER.info("Discovered a new Nanoleaf: %s", discovery_info) conf = load_json(hass.config.path(CONFIG_FILE)) if conf.get(host, {}).get('token'): token = conf[host]['token'] @@ -64,8 +63,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] token = config[CONF_TOKEN] + nanoleaf_light = Nanoleaf(host) + if not token: - token = nanoleaf.setup.generate_auth_token(host) + token = nanoleaf_light.request_token() if not token: _LOGGER.error("Could not generate the auth token, did you press " "and hold the power button on %s" @@ -75,22 +76,25 @@ def setup_platform(hass, config, add_entities, discovery_info=None): conf[host] = {'token': token} save_json(hass.config.path(CONFIG_FILE), conf) - aurora_light = nanoleaf.Aurora(host, token) + nanoleaf_light.token = token - if aurora_light.on is None: + try: + nanoleaf_light.available + except Unavailable: _LOGGER.error( - "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + "Could not connect to Nanoleaf Light: %s on %s", name, host) return - hass.data[DATA_NANOLEAF_AURORA][host] = aurora_light - add_entities([AuroraLight(aurora_light, name)], True) + hass.data[DATA_NANOLEAF][host] = nanoleaf_light + add_entities([NanoleafLight(nanoleaf_light, name)], True) -class AuroraLight(Light): - """Representation of a Nanoleaf Aurora.""" +class NanoleafLight(Light): + """Representation of a Nanoleaf Light.""" def __init__(self, light, name): - """Initialize an Aurora light.""" + """Initialize an Nanoleaf light.""" + self._available = True self._brightness = None self._color_temp = None self._effect = None @@ -100,6 +104,11 @@ class AuroraLight(Light): self._hs_color = None self._state = None + @property + def available(self): + """Return availability.""" + return self._available + @property def brightness(self): """Return the brightness of the light.""" @@ -158,7 +167,7 @@ class AuroraLight(Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_AURORA + return SUPPORT_NANOLEAF def turn_on(self, **kwargs): """Instruct the light to turn on.""" @@ -186,9 +195,15 @@ class AuroraLight(Light): def update(self): """Fetch new state data for this light.""" - self._brightness = self._light.brightness - self._color_temp = self._light.color_temperature - self._effect = self._light.effect - self._effects_list = self._light.effects_list - self._hs_color = self._light.hue, self._light.saturation - self._state = self._light.on + try: + self._available = self._light.available + self._brightness = self._light.brightness + self._color_temp = self._light.color_temperature + self._effect = self._light.effect + self._effects_list = self._light.effects + self._hs_color = self._light.hue, self._light.saturation + self._state = self._light.on + except Exception as err: # pylint:disable=broad-except + _LOGGER.error("Could not update status for %s (%s)", + self.name, err) + self._available = False diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 10cbeb42aa4..a2863482477 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -68,12 +68,8 @@ turn_off: toggle: description: Toggles a light. fields: - entity_id: - description: Name(s) of entities to toggle. - example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state. - example: 60 + '...': + description: All turn_on parameters can be used. hue_activate_scene: description: Activate a hue scene stored in the hue hub. diff --git a/homeassistant/components/locative/.translations/cs.json b/homeassistant/components/locative/.translations/cs.json new file mode 100644 index 00000000000..d48b6ff13d9 --- /dev/null +++ b/homeassistant/components/locative/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e instanci dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu a p\u0159ij\u00edmat zpr\u00e1vy od spole\u010dnosti Geofency.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odes\u00edlat um\u00edst\u011bn\u00ed do aplikace Home Assistant, budete muset nastavit funkci Webhook v aplikaci Locative. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Locative Webhook?", + "title": "Nastavit Locative Webhook" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/nl.json b/homeassistant/components/locative/.translations/nl.json index 237d21c46ee..26ec0951d88 100644 --- a/homeassistant/components/locative/.translations/nl.json +++ b/homeassistant/components/locative/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_internet_accessible": "Je Home Assistant instance moet bereikbaar zijn vanuit het internet om berichten van Geofency te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." }, "create_entry": { diff --git a/homeassistant/components/locative/.translations/zh-Hans.json b/homeassistant/components/locative/.translations/zh-Hans.json index 96626a57c5b..967671de535 100644 --- a/homeassistant/components/locative/.translations/zh-Hans.json +++ b/homeassistant/components/locative/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "not_internet_accessible": "\u60a8\u7684Home Assistant\u5b9e\u4f8b\u9700\u8981\u53ef\u4ee5\u4eceInternet\u8bbf\u95ee\u4ee5\u63a5\u6536\u6765\u81eaGeofency\u7684\u6d88\u606f\u3002", + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002", "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" }, "step": { diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index dbedc8c6d70..7a0fb5e2654 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -146,8 +146,8 @@ class LogbookView(HomeAssistantView): def json_events(): """Fetch events and generate JSON.""" - return self.json(list( - _get_events(hass, self.config, start_day, end_day, entity_id))) + return self.json( + _get_events(hass, self.config, start_day, end_day, entity_id)) return await hass.async_add_job(json_events) @@ -393,11 +393,17 @@ def _generate_filter_from_config(config): def _get_events(hass, config, start_day, end_day, entity_id=None): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events, States - from homeassistant.components.recorder.util import ( - execute, session_scope) + from homeassistant.components.recorder.util import session_scope entities_filter = _generate_filter_from_config(config) + def yield_events(query): + """Yield Events that are not filtered away.""" + for row in query.yield_per(500): + event = row.to_native() + if _keep_event(event, entities_filter): + yield event + with session_scope(hass=hass) as session: if entity_id is not None: entity_ids = [entity_id.lower()] @@ -413,77 +419,70 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): States.entity_id.in_(entity_ids)) | (States.state_id.is_(None))) - events = execute(query) - - return humanify(hass, _exclude_events(events, entities_filter)) + return list(humanify(hass, yield_events(query))) -def _exclude_events(events, entities_filter): - filtered_events = [] - for event in events: - domain, entity_id = None, None +def _keep_event(event, entities_filter): + domain, entity_id = None, None - if event.event_type == EVENT_STATE_CHANGED: - entity_id = event.data.get('entity_id') + if event.event_type == EVENT_STATE_CHANGED: + entity_id = event.data.get('entity_id') - if entity_id is None: - continue + if entity_id is None: + return False - # Do not report on new entities - if event.data.get('old_state') is None: - continue + # Do not report on new entities + if event.data.get('old_state') is None: + return False - new_state = event.data.get('new_state') + new_state = event.data.get('new_state') - # Do not report on entity removal - if not new_state: - continue + # Do not report on entity removal + if not new_state: + return False - attributes = new_state.get('attributes', {}) + attributes = new_state.get('attributes', {}) - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. - last_changed = new_state.get('last_changed') - last_updated = new_state.get('last_updated') - if last_changed != last_updated: - continue + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + return False - domain = split_entity_id(entity_id)[0] + domain = split_entity_id(entity_id)[0] - # Also filter auto groups. - if domain == 'group' and attributes.get('auto', False): - continue + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): + return False - # exclude entities which are customized hidden - hidden = attributes.get(ATTR_HIDDEN, False) - if hidden: - continue + # exclude entities which are customized hidden + hidden = attributes.get(ATTR_HIDDEN, False) + if hidden: + return False - elif event.event_type == EVENT_LOGBOOK_ENTRY: - domain = event.data.get(ATTR_DOMAIN) - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_LOGBOOK_ENTRY: + domain = event.data.get(ATTR_DOMAIN) + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_AUTOMATION_TRIGGERED: - domain = 'automation' - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + domain = 'automation' + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_SCRIPT_STARTED: - domain = 'script' - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_SCRIPT_STARTED: + domain = 'script' + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_ALEXA_SMART_HOME: - domain = 'alexa' + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' - elif event.event_type == EVENT_HOMEKIT_CHANGED: - domain = DOMAIN_HOMEKIT + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT - if not entity_id and domain: - entity_id = "%s." % (domain, ) + if not entity_id and domain: + entity_id = "%s." % (domain, ) - if not entity_id or entities_filter(entity_id): - filtered_events.append(event) - - return filtered_events + return not entity_id or entities_filter(entity_id) def _entry_message_from_state(domain, state): diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index fae44d3584d..f642e96d8f6 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -124,7 +124,8 @@ class LutronButton: """Register callback for activity on the button.""" name = '{}: {}'.format(keypad.name, button.name) self._hass = hass - self._has_release_event = 'RaiseLower' in button.button_type + self._has_release_event = (button.button_type is not None and + 'RaiseLower' in button.button_type) self._id = slugify(name) self._event = 'lutron_event' diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index d0b3c9e3c00..ff6277242ca 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2019.02.18'] +REQUIREMENTS = ['youtube_dl==2019.03.01'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index f867a10ccd0..36bc5ae10e1 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['anthemav==1.1.9'] +REQUIREMENTS = ['anthemav==1.1.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 03015cd5c01..fbb0ee58c5a 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -9,6 +9,7 @@ from datetime import datetime from datetime import timedelta import functools import logging +from typing import Optional import aiohttp import voluptuous as vol @@ -22,13 +23,13 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( CONF_NAME, CONF_URL, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) -from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.14.4'] +REQUIREMENTS = ['async-upnp-client==0.14.5'] _LOGGER = logging.getLogger(__name__) @@ -39,12 +40,14 @@ DEFAULT_LISTEN_PORT = 8301 CONF_LISTEN_IP = 'listen_ip' CONF_LISTEN_PORT = 'listen_port' +CONF_CALLBACK_URL_OVERRIDE = 'callback_url_override' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, vol.Optional(CONF_LISTEN_IP): cv.string, vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, }) HOME_ASSISTANT_UPNP_CLASS_MAPPING = { @@ -82,7 +85,12 @@ def catch_request_errors(): return call_wrapper -async def async_start_event_handler(hass, server_host, server_port, requester): +async def async_start_event_handler( + hass: HomeAssistantType, + server_host: str, + server_port: int, + requester, + callback_url_override: Optional[str] = None): """Register notify view.""" hass_data = hass.data[DLNA_DMR_DATA] if 'event_handler' in hass_data: @@ -91,10 +99,14 @@ async def async_start_event_handler(hass, server_host, server_port, requester): # start event handler from async_upnp_client.aiohttp import AiohttpNotifyServer server = AiohttpNotifyServer( - requester, server_port, server_host, hass.loop) + requester, + listen_port=server_port, + listen_host=server_host, + loop=hass.loop, + callback_url=callback_url_override) await server.start_server() _LOGGER.info( - 'UPNP/DLNA event handler listening on: %s', server.callback_url) + 'UPNP/DLNA event handler listening, url: %s', server.callback_url) hass_data['notify_server'] = server hass_data['event_handler'] = server.event_handler @@ -109,7 +121,10 @@ async def async_start_event_handler(hass, server_host, server_port, requester): async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None): + hass: HomeAssistantType, + config, + async_add_entities, + discovery_info=None): """Set up DLNA DMR platform.""" if config.get(CONF_URL) is not None: url = config[CONF_URL] @@ -135,8 +150,9 @@ async def async_setup_platform( if server_host is None: server_host = get_local_ip() server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) event_handler = await async_start_event_handler( - hass, server_host, server_port, requester) + hass, server_host, server_port, requester, callback_url_override) # create upnp device from async_upnp_client import UpnpFactory diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py deleted file mode 100644 index fb7df736e51..00000000000 --- a/homeassistant/components/media_player/firetv.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Support for functionality to interact with FireTV devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.firetv/ -""" -import functools -import logging -import voluptuous as vol - -from homeassistant.components.media_player import ( - MediaPlayerDevice, PLATFORM_SCHEMA) -from homeassistant.components.media_player.const import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON) -from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, - STATE_PLAYING, STATE_STANDBY) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['firetv==1.0.9'] - -_LOGGER = logging.getLogger(__name__) - -SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP - -CONF_ADBKEY = 'adbkey' -CONF_ADB_SERVER_IP = 'adb_server_ip' -CONF_ADB_SERVER_PORT = 'adb_server_port' -CONF_GET_SOURCES = 'get_sources' - -DEFAULT_NAME = 'Amazon Fire TV' -DEFAULT_PORT = 5555 -DEFAULT_ADB_SERVER_PORT = 5037 -DEFAULT_GET_SOURCES = True - - -def has_adb_files(value): - """Check that ADB key files exist.""" - priv_key = value - pub_key = '{}.pub'.format(value) - cv.isfile(pub_key) - return cv.isfile(priv_key) - - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_ADBKEY): has_adb_files, - vol.Optional(CONF_ADB_SERVER_IP): cv.string, - vol.Optional( - CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, - vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean -}) - -# Translate from `FireTV` reported state to HA state. -FIRETV_STATES = {'off': STATE_OFF, - 'idle': STATE_IDLE, - 'standby': STATE_STANDBY, - 'playing': STATE_PLAYING, - 'paused': STATE_PAUSED} - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the FireTV platform.""" - from firetv import FireTV - - host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) - - if CONF_ADB_SERVER_IP not in config: - # Use "python-adb" (Python ADB implementation) - if CONF_ADBKEY in config: - ftv = FireTV(host, config[CONF_ADBKEY]) - adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) - else: - ftv = FireTV(host) - adb_log = "" - else: - # Use "pure-python-adb" (communicate with ADB server) - ftv = FireTV(host, adb_server_ip=config[CONF_ADB_SERVER_IP], - adb_server_port=config[CONF_ADB_SERVER_PORT]) - adb_log = " using ADB server at {0}:{1}".format( - config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT]) - - if not ftv.available: - _LOGGER.warning("Could not connect to Fire TV at %s%s", host, adb_log) - return - - name = config[CONF_NAME] - get_sources = config[CONF_GET_SOURCES] - - device = FireTVDevice(ftv, name, get_sources) - add_entities([device]) - _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) - - -def adb_decorator(override_available=False): - """Send an ADB command if the device is available and catch exceptions.""" - def _adb_decorator(func): - """Wait if previous ADB commands haven't finished.""" - @functools.wraps(func) - def _adb_exception_catcher(self, *args, **kwargs): - # If the device is unavailable, don't do anything - if not self.available and not override_available: - return None - - try: - return func(self, *args, **kwargs) - except self.exceptions as err: - _LOGGER.error( - "Failed to execute an ADB command. ADB connection re-" - "establishing attempt in the next update. Error: %s", err) - self._available = False # pylint: disable=protected-access - return None - - return _adb_exception_catcher - - return _adb_decorator - - -class FireTVDevice(MediaPlayerDevice): - """Representation of an Amazon Fire TV device on the network.""" - - def __init__(self, ftv, name, get_sources): - """Initialize the FireTV device.""" - self.firetv = ftv - - self._name = name - self._get_sources = get_sources - - # ADB exceptions to catch - if not self.firetv.adb_server_ip: - # Using "python-adb" (Python ADB implementation) - from adb.adb_protocol import (InvalidChecksumError, - InvalidCommandError, - InvalidResponseError) - from adb.usb_exceptions import TcpTimeoutException - - self.exceptions = (AttributeError, BrokenPipeError, TypeError, - ValueError, InvalidChecksumError, - InvalidCommandError, InvalidResponseError, - TcpTimeoutException) - else: - # Using "pure-python-adb" (communicate with ADB server) - self.exceptions = (ConnectionResetError,) - - self._state = None - self._available = self.firetv.available - self._current_app = None - self._running_apps = None - - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def should_poll(self): - """Device should be polled.""" - return True - - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_FIRETV - - @property - def state(self): - """Return the state of the player.""" - return self._state - - @property - def available(self): - """Return whether or not the ADB connection is valid.""" - return self._available - - @property - def app_id(self): - """Return the current app.""" - return self._current_app - - @property - def source(self): - """Return the current app.""" - return self._current_app - - @property - def source_list(self): - """Return a list of running apps.""" - return self._running_apps - - @adb_decorator(override_available=True) - def update(self): - """Update the device state and, if necessary, re-connect.""" - # Check if device is disconnected. - if not self._available: - # Try to connect - self._available = self.firetv.connect() - - # To be safe, wait until the next update to run ADB commands. - return - - # If the ADB connection is not intact, don't update. - if not self._available: - return - - # Get the `state`, `current_app`, and `running_apps`. - ftv_state, self._current_app, self._running_apps = \ - self.firetv.update(self._get_sources) - - self._state = FIRETV_STATES[ftv_state] - - @adb_decorator() - def turn_on(self): - """Turn on the device.""" - self.firetv.turn_on() - - @adb_decorator() - def turn_off(self): - """Turn off the device.""" - self.firetv.turn_off() - - @adb_decorator() - def media_play(self): - """Send play command.""" - self.firetv.media_play() - - @adb_decorator() - def media_pause(self): - """Send pause command.""" - self.firetv.media_pause() - - @adb_decorator() - def media_play_pause(self): - """Send play/pause command.""" - self.firetv.media_play_pause() - - @adb_decorator() - def media_stop(self): - """Send stop (back) command.""" - self.firetv.back() - - @adb_decorator() - def volume_up(self): - """Send volume up command.""" - self.firetv.volume_up() - - @adb_decorator() - def volume_down(self): - """Send volume down command.""" - self.firetv.volume_down() - - @adb_decorator() - def media_previous_track(self): - """Send previous track command (results in rewind).""" - self.firetv.media_previous() - - @adb_decorator() - def media_next_track(self): - """Send next track command (results in fast-forward).""" - self.firetv.media_next() - - @adb_decorator() - def select_source(self, source): - """Select input source. - - If the source starts with a '!', then it will close the app instead of - opening it. - """ - if isinstance(source, str): - if not source.startswith('!'): - self.firetv.launch_app(source) - else: - self.firetv.stop_app(source[1:].lstrip()) diff --git a/homeassistant/components/mobile_app/.translations/en.json b/homeassistant/components/mobile_app/.translations/en.json new file mode 100644 index 00000000000..646151a5229 --- /dev/null +++ b/homeassistant/components/mobile_app/.translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Mobile App", + "step": { + "confirm": { + "title": "Mobile App", + "description": "Do you want to set up the Mobile App component?" + } + }, + "abort": { + "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps." + } + } +} diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 19a81b4aa45..ecbe8d70847 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,355 +1,117 @@ -"""Support for native mobile apps.""" -import logging -import json -from functools import partial - -import voluptuous as vol -from aiohttp.web import json_response, Response -from aiohttp.web_exceptions import HTTPBadRequest - +"""Integrates Native Apps to Home Assistant.""" from homeassistant import config_entries -from homeassistant.auth.util import generate_secret -import homeassistant.core as ha -from homeassistant.core import Context -from homeassistant.components import webhook -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, SERVICE_SEE as DEVICE_TRACKER_SEE, - SERVICE_SEE_PAYLOAD_SCHEMA as SEE_SCHEMA) -from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, - HTTP_BAD_REQUEST, HTTP_CREATED, - HTTP_INTERNAL_SERVER_ERROR, CONF_WEBHOOK_ID) -from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, - TemplateError) -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.components.webhook import async_register as webhook_register +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -REQUIREMENTS = ['PyNaCl==1.3.0'] +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, + DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, + STORAGE_VERSION) -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'mobile_app' +from .http_api import RegistrationsView +from .webhook import handle_webhook +from .websocket_api import register_websocket_handlers DEPENDENCIES = ['device_tracker', 'http', 'webhook'] -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -CONF_SECRET = 'secret' -CONF_USER_ID = 'user_id' - -ATTR_APP_DATA = 'app_data' -ATTR_APP_ID = 'app_id' -ATTR_APP_NAME = 'app_name' -ATTR_APP_VERSION = 'app_version' -ATTR_DEVICE_NAME = 'device_name' -ATTR_MANUFACTURER = 'manufacturer' -ATTR_MODEL = 'model' -ATTR_OS_VERSION = 'os_version' -ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' - -ATTR_EVENT_DATA = 'event_data' -ATTR_EVENT_TYPE = 'event_type' - -ATTR_TEMPLATE = 'template' -ATTR_TEMPLATE_VARIABLES = 'variables' - -ATTR_WEBHOOK_DATA = 'data' -ATTR_WEBHOOK_ENCRYPTED = 'encrypted' -ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' -ATTR_WEBHOOK_TYPE = 'type' - -WEBHOOK_TYPE_CALL_SERVICE = 'call_service' -WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' -WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' -WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' -WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' - -WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION] - -REGISTER_DEVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_APP_DATA, default={}): dict, - vol.Required(ATTR_APP_ID): cv.string, - vol.Optional(ATTR_APP_NAME): cv.string, - vol.Required(ATTR_APP_VERSION): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_MANUFACTURER): cv.string, - vol.Required(ATTR_MODEL): cv.string, - vol.Optional(ATTR_OS_VERSION): cv.string, - vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, -}) - -UPDATE_DEVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_APP_DATA, default={}): dict, - vol.Required(ATTR_APP_VERSION): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_MANUFACTURER): cv.string, - vol.Required(ATTR_MODEL): cv.string, - vol.Optional(ATTR_OS_VERSION): cv.string, -}) - -WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ - vol.Required(ATTR_WEBHOOK_TYPE): vol.In(WEBHOOK_TYPES), - vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, - vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, - vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, -}) - -CALL_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_DOMAIN): cv.string, - vol.Required(ATTR_SERVICE): cv.string, - vol.Optional(ATTR_SERVICE_DATA, default={}): dict, -}) - -FIRE_EVENT_SCHEMA = vol.Schema({ - vol.Required(ATTR_EVENT_TYPE): cv.string, - vol.Optional(ATTR_EVENT_DATA, default={}): dict, -}) - -RENDER_TEMPLATE_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEMPLATE): cv.string, - vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, -}) - -WEBHOOK_SCHEMAS = { - WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, - WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, - WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, - WEBHOOK_TYPE_UPDATE_LOCATION: SEE_SCHEMA, - WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_DEVICE_SCHEMA, -} +REQUIREMENTS = ['PyNaCl==1.3.0'] -def get_cipher(): - """Return decryption function and length of key. - - Async friendly. - """ - from nacl.secret import SecretBox - from nacl.encoding import Base64Encoder - - def decrypt(ciphertext, key): - """Decrypt ciphertext using key.""" - return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) - return (SecretBox.KEY_SIZE, decrypt) - - -def _decrypt_payload(key, ciphertext): - """Decrypt encrypted payload.""" - try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known") - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - message = decrypt(ciphertext, key) - message = json.loads(message.decode("utf-8")) - _LOGGER.debug("Successfully decrypted mobile_app payload") - return message - except ValueError: - _LOGGER.warning("Ignoring encrypted payload because unable to decrypt") - return None - - -def context(device): - """Generate a context from a request.""" - return Context(user_id=device[CONF_USER_ID]) - - -async def handle_webhook(store, hass: HomeAssistantType, webhook_id: str, - request): - """Handle webhook callback.""" - device = hass.data[DOMAIN][webhook_id] - - try: - req_data = await request.json() - except ValueError: - _LOGGER.warning('Received invalid JSON from mobile_app') - return json_response([], status=HTTP_BAD_REQUEST) - - try: - req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) - except vol.Invalid as ex: - err = vol.humanize.humanize_error(req_data, ex) - _LOGGER.error('Received invalid webhook payload: %s', err) - return Response(status=200) - - webhook_type = req_data[ATTR_WEBHOOK_TYPE] - - webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {}) - - if req_data[ATTR_WEBHOOK_ENCRYPTED]: - enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] - webhook_payload = _decrypt_payload(device[CONF_SECRET], enc_data) - - try: - data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) - except vol.Invalid as ex: - err = vol.humanize.humanize_error(webhook_payload, ex) - _LOGGER.error('Received invalid webhook payload: %s', err) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_CALL_SERVICE: - try: - await hass.services.async_call(data[ATTR_DOMAIN], - data[ATTR_SERVICE], - data[ATTR_SERVICE_DATA], - blocking=True, - context=context(device)) - except (vol.Invalid, ServiceNotFound): - raise HTTPBadRequest() - - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_FIRE_EVENT: - event_type = data[ATTR_EVENT_TYPE] - hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA], - ha.EventOrigin.remote, context=context(device)) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: - try: - tpl = template.Template(data[ATTR_TEMPLATE], hass) - rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) - return json_response({"rendered": rendered}) - except (ValueError, TemplateError) as ex: - return json_response(({"error": ex}), status=HTTP_BAD_REQUEST) - - if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: - await hass.services.async_call(DEVICE_TRACKER_DOMAIN, - DEVICE_TRACKER_SEE, data, - blocking=True, context=context(device)) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: - data[ATTR_APP_ID] = device[ATTR_APP_ID] - data[ATTR_APP_NAME] = device[ATTR_APP_NAME] - data[ATTR_SUPPORTS_ENCRYPTION] = device[ATTR_SUPPORTS_ENCRYPTION] - data[CONF_SECRET] = device[CONF_SECRET] - data[CONF_USER_ID] = device[CONF_USER_ID] - data[CONF_WEBHOOK_ID] = device[CONF_WEBHOOK_ID] - - hass.data[DOMAIN][webhook_id] = data - - try: - await store.async_save(hass.data[DOMAIN]) - except HomeAssistantError as ex: - _LOGGER.error("Error updating mobile_app registration: %s", ex) - return Response(status=200) - - return json_response(safe_device(data)) - - -def supports_encryption(): - """Test if we support encryption.""" - try: - import nacl # noqa pylint: disable=unused-import - return True - except OSError: - return False - - -def safe_device(device: dict): - """Return a device without webhook_id or secret.""" - return { - ATTR_APP_DATA: device[ATTR_APP_DATA], - ATTR_APP_ID: device[ATTR_APP_ID], - ATTR_APP_NAME: device[ATTR_APP_NAME], - ATTR_APP_VERSION: device[ATTR_APP_VERSION], - ATTR_DEVICE_NAME: device[ATTR_DEVICE_NAME], - ATTR_MANUFACTURER: device[ATTR_MANUFACTURER], - ATTR_MODEL: device[ATTR_MODEL], - ATTR_OS_VERSION: device[ATTR_OS_VERSION], - ATTR_SUPPORTS_ENCRYPTION: device[ATTR_SUPPORTS_ENCRYPTION], - } - - -def register_device_webhook(hass: HomeAssistantType, store, device): - """Register the webhook for a device.""" - device_name = 'Mobile App: {}'.format(device[ATTR_DEVICE_NAME]) - webhook_id = device[CONF_WEBHOOK_ID] - webhook.async_register(hass, DOMAIN, device_name, webhook_id, - partial(handle_webhook, store)) - - -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" - conf = config.get(DOMAIN) - store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() if app_config is None: - app_config = {} + app_config = { + DATA_BINARY_SENSOR: {}, + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: [], + DATA_DEVICES: {}, + DATA_SENSOR: {} + } - hass.data[DOMAIN] = app_config + hass.data[DOMAIN] = { + DATA_BINARY_SENSOR: app_config.get(DATA_BINARY_SENSOR, {}), + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), + DATA_DEVICES: {}, + DATA_SENSOR: app_config.get(DATA_SENSOR, {}), + DATA_STORE: store, + } - for device in app_config.values(): - register_device_webhook(hass, store, device) + hass.http.register_view(RegistrationsView()) + register_websocket_handlers(hass) - if conf is not None: - hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) - - hass.http.register_view(DevicesView(store)) + for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]: + try: + webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id, + handle_webhook) + except ValueError: + pass return True async def async_setup_entry(hass, entry): - """Set up an mobile_app entry.""" + """Set up a mobile_app entry.""" + registration = entry.data + + webhook_id = registration[CONF_WEBHOOK_ID] + + hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] = entry + + device_registry = await dr.async_get_registry(hass) + + identifiers = { + (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID]) + } + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=identifiers, + manufacturer=registration[ATTR_MANUFACTURER], + model=registration[ATTR_MODEL], + name=registration[ATTR_DEVICE_NAME], + sw_version=registration[ATTR_OS_VERSION] + ) + + hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device + + registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME]) + webhook_register(hass, DOMAIN, registration_name, webhook_id, + handle_webhook) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, + DATA_BINARY_SENSOR)) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DATA_SENSOR)) + return True -class DevicesView(HomeAssistantView): - """A view that accepts device registration requests.""" +@config_entries.HANDLERS.register(DOMAIN) +class MobileAppFlowHandler(config_entries.ConfigFlow): + """Handle a Mobile App config flow.""" - url = '/api/mobile_app/devices' - name = 'api:mobile_app:register-device' + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - def __init__(self, store): - """Initialize the view.""" - self._store = store + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + placeholders = { + 'apps_url': + 'https://www.home-assistant.io/components/mobile_app/#apps' + } - @RequestDataValidator(REGISTER_DEVICE_SCHEMA) - async def post(self, request, data): - """Handle the POST request for device registration.""" - hass = request.app['hass'] + return self.async_abort(reason='install_app', + description_placeholders=placeholders) - resp = {} - - webhook_id = generate_secret() - - data[CONF_WEBHOOK_ID] = resp[CONF_WEBHOOK_ID] = webhook_id - - if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): - secret = generate_secret(16) - - data[CONF_SECRET] = resp[CONF_SECRET] = secret - - data[CONF_USER_ID] = request['hass_user'].id - - hass.data[DOMAIN][webhook_id] = data - - try: - await self._store.async_save(hass.data[DOMAIN]) - except HomeAssistantError: - return self.json_message("Error saving device.", - HTTP_INTERNAL_SERVER_ERROR) - - register_device_webhook(hass, self._store, data) - - return self.json(resp, status_code=HTTP_CREATED) + async def async_step_registration(self, user_input=None): + """Handle a flow initialized during registration.""" + return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME], + data=user_input) diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py new file mode 100644 index 00000000000..289a50584c9 --- /dev/null +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_BINARY_SENSOR as ENTITY_TYPE, + DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app binary sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppBinarySensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppBinarySensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppBinarySensor(MobileAppEntity, BinarySensorDevice): + """Representation of an mobile app binary sensor.""" + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._config[ATTR_SENSOR_STATE] diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py new file mode 100644 index 00000000000..3aa4626da29 --- /dev/null +++ b/homeassistant/components/mobile_app/const.py @@ -0,0 +1,185 @@ +"""Constants for mobile_app.""" +import voluptuous as vol + +from homeassistant.components.binary_sensor import (DEVICE_CLASSES as + BINARY_SENSOR_CLASSES) +from homeassistant.components.sensor import DEVICE_CLASSES as SENSOR_CLASSES +from homeassistant.components.device_tracker import (ATTR_BATTERY, + ATTR_GPS, + ATTR_GPS_ACCURACY, + ATTR_LOCATION_NAME) +from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA) +from homeassistant.helpers import config_validation as cv + +DOMAIN = 'mobile_app' + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CONF_CLOUDHOOK_URL = 'cloudhook_url' +CONF_REMOTE_UI_URL = 'remote_ui_url' +CONF_SECRET = 'secret' +CONF_USER_ID = 'user_id' + +DATA_BINARY_SENSOR = 'binary_sensor' +DATA_CONFIG_ENTRIES = 'config_entries' +DATA_DELETED_IDS = 'deleted_ids' +DATA_DEVICES = 'devices' +DATA_SENSOR = 'sensor' +DATA_STORE = 'store' + +ATTR_APP_COMPONENT = 'app_component' +ATTR_APP_DATA = 'app_data' +ATTR_APP_ID = 'app_id' +ATTR_APP_NAME = 'app_name' +ATTR_APP_VERSION = 'app_version' +ATTR_CONFIG_ENTRY_ID = 'entry_id' +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_NAME = 'device_name' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MODEL = 'model' +ATTR_OS_NAME = 'os_name' +ATTR_OS_VERSION = 'os_version' +ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' + +ATTR_EVENT_DATA = 'event_data' +ATTR_EVENT_TYPE = 'event_type' + +ATTR_TEMPLATE = 'template' +ATTR_TEMPLATE_VARIABLES = 'variables' + +ATTR_SPEED = 'speed' +ATTR_ALTITUDE = 'altitude' +ATTR_COURSE = 'course' +ATTR_VERTICAL_ACCURACY = 'vertical_accuracy' + +ATTR_WEBHOOK_DATA = 'data' +ATTR_WEBHOOK_ENCRYPTED = 'encrypted' +ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' +ATTR_WEBHOOK_TYPE = 'type' + +ERR_ENCRYPTION_REQUIRED = 'encryption_required' +ERR_INVALID_COMPONENT = 'invalid_component' +ERR_SENSOR_NOT_REGISTERED = 'not_registered' +ERR_SENSOR_DUPLICATE_UNIQUE_ID = 'duplicate_unique_id' + +WEBHOOK_TYPE_CALL_SERVICE = 'call_service' +WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' +WEBHOOK_TYPE_REGISTER_SENSOR = 'register_sensor' +WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' +WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' +WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' +WEBHOOK_TYPE_UPDATE_SENSOR_STATES = 'update_sensor_states' + +WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, + WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES] + + +REGISTRATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_COMPONENT): cv.string, + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_ID): cv.string, + vol.Required(ATTR_APP_NAME): cv.string, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Required(ATTR_OS_NAME): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, + vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, +}) + +UPDATE_REGISTRATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, +}) + +WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ + vol.Required(ATTR_WEBHOOK_TYPE): cv.string, # vol.In(WEBHOOK_TYPES) + vol.Required(ATTR_WEBHOOK_DATA, default={}): vol.Any(dict, list), + vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, + vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, +}) + +CALL_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_DOMAIN): cv.string, + vol.Required(ATTR_SERVICE): cv.string, + vol.Optional(ATTR_SERVICE_DATA, default={}): dict, +}) + +FIRE_EVENT_SCHEMA = vol.Schema({ + vol.Required(ATTR_EVENT_TYPE): cv.string, + vol.Optional(ATTR_EVENT_DATA, default={}): dict, +}) + +RENDER_TEMPLATE_SCHEMA = vol.Schema({ + str: { + vol.Required(ATTR_TEMPLATE): cv.template, + vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, + } +}) + +UPDATE_LOCATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_LOCATION_NAME): cv.string, + vol.Required(ATTR_GPS): cv.gps, + vol.Required(ATTR_GPS_ACCURACY): cv.positive_int, + vol.Optional(ATTR_BATTERY): cv.positive_int, + vol.Optional(ATTR_SPEED): cv.positive_int, + vol.Optional(ATTR_ALTITUDE): cv.positive_int, + vol.Optional(ATTR_COURSE): cv.positive_int, + vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, +}) + +ATTR_SENSOR_ATTRIBUTES = 'attributes' +ATTR_SENSOR_DEVICE_CLASS = 'device_class' +ATTR_SENSOR_ICON = 'icon' +ATTR_SENSOR_NAME = 'name' +ATTR_SENSOR_STATE = 'state' +ATTR_SENSOR_TYPE = 'type' +ATTR_SENSOR_TYPE_BINARY_SENSOR = 'binary_sensor' +ATTR_SENSOR_TYPE_SENSOR = 'sensor' +ATTR_SENSOR_UNIQUE_ID = 'unique_id' +ATTR_SENSOR_UOM = 'unit_of_measurement' + +SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR] + +COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)) + +SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update' + +REGISTER_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All(vol.Lower, + vol.In(COMBINED_CLASSES)), + vol.Required(ATTR_SENSOR_NAME): cv.string, + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, + vol.Required(ATTR_SENSOR_UOM): cv.string, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, +}) + +UPDATE_SENSOR_STATE_SCHEMA = vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, +})]) + +WEBHOOK_SCHEMAS = { + WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, + WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, + WEBHOOK_TYPE_REGISTER_SENSOR: REGISTER_SENSOR_SCHEMA, + WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, + WEBHOOK_TYPE_UPDATE_LOCATION: UPDATE_LOCATION_SCHEMA, + WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES: UPDATE_SENSOR_STATE_SCHEMA, +} diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py new file mode 100644 index 00000000000..05736b3a689 --- /dev/null +++ b/homeassistant/components/mobile_app/entity.py @@ -0,0 +1,98 @@ +"""A entity class for mobile_app.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, + ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, + ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, + DOMAIN, SIGNAL_SENSOR_UPDATE) + + +class MobileAppEntity(Entity): + """Representation of an mobile app entity.""" + + def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry): + """Initialize the sensor.""" + self._config = config + self._device = device + self._entry = entry + self._registration = entry.data + self._sensor_id = "{}_{}".format(self._registration[CONF_WEBHOOK_ID], + config[ATTR_SENSOR_UNIQUE_ID]) + self._entity_type = config[ATTR_SENSOR_TYPE] + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Register callbacks.""" + self.unsub_dispatcher = async_dispatcher_connect(self.hass, + SIGNAL_SENSOR_UPDATE, + self._handle_update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def name(self): + """Return the name of the mobile app sensor.""" + return self._config[ATTR_SENSOR_NAME] + + @property + def device_class(self): + """Return the device class.""" + return self._config.get(ATTR_SENSOR_DEVICE_CLASS) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._config[ATTR_SENSOR_ATTRIBUTES] + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._config[ATTR_SENSOR_ICON] + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return self._sensor_id + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + 'identifiers': { + (ATTR_DEVICE_ID, self._registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, self._registration[CONF_WEBHOOK_ID]) + }, + 'manufacturer': self._registration[ATTR_MANUFACTURER], + 'model': self._registration[ATTR_MODEL], + 'device_name': self._registration[ATTR_DEVICE_NAME], + 'sw_version': self._registration[ATTR_OS_VERSION], + 'config_entries': self._device.config_entries + } + + async def async_update(self): + """Get the latest state of the sensor.""" + data = self.hass.data[DOMAIN] + try: + self._config = data[self._entity_type][self._sensor_id] + except KeyError: + return + + @callback + def _handle_update(self, data): + """Handle async event updates.""" + self._config = data + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py new file mode 100644 index 00000000000..60bd8b4e1d6 --- /dev/null +++ b/homeassistant/components/mobile_app/helpers.py @@ -0,0 +1,149 @@ +"""Helpers for mobile_app.""" +import logging +import json +from typing import Callable, Dict, Tuple + +from aiohttp.web import json_response, Response + +from homeassistant.core import Context +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, + ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, + CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR, + DATA_DELETED_IDS, DATA_SENSOR, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +def setup_decrypt() -> Tuple[int, Callable]: + """Return decryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def decrypt(ciphertext, key): + """Decrypt ciphertext using key.""" + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) + + +def setup_encrypt() -> Tuple[int, Callable]: + """Return encryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def encrypt(ciphertext, key): + """Encrypt ciphertext using key.""" + return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, encrypt) + + +def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]: + """Decrypt encrypted payload.""" + try: + keylen, decrypt = setup_decrypt() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known") + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + message = decrypt(ciphertext, key) + message = json.loads(message.decode("utf-8")) + _LOGGER.debug("Successfully decrypted mobile_app payload") + return message + except ValueError: + _LOGGER.warning("Ignoring encrypted payload because unable to decrypt") + return None + + +def registration_context(registration: Dict) -> Context: + """Generate a context from a request.""" + return Context(user_id=registration[CONF_USER_ID]) + + +def empty_okay_response(headers: Dict = None, status: int = 200) -> Response: + """Return a Response with empty JSON object and a 200.""" + return Response(body='{}', status=status, content_type='application/json', + headers=headers) + + +def error_response(code: str, message: str, status: int = 400, + headers: dict = None) -> Response: + """Return an error Response.""" + return json_response({ + 'success': False, + 'error': { + 'code': code, + 'message': message + } + }, status=status, headers=headers) + + +def supports_encryption() -> bool: + """Test if we support encryption.""" + try: + import nacl # noqa pylint: disable=unused-import + return True + except OSError: + return False + + +def safe_registration(registration: Dict) -> Dict: + """Return a registration without sensitive values.""" + # Sensitive values: webhook_id, secret, cloudhook_url + return { + ATTR_APP_DATA: registration[ATTR_APP_DATA], + ATTR_APP_ID: registration[ATTR_APP_ID], + ATTR_APP_NAME: registration[ATTR_APP_NAME], + ATTR_APP_VERSION: registration[ATTR_APP_VERSION], + ATTR_DEVICE_NAME: registration[ATTR_DEVICE_NAME], + ATTR_MANUFACTURER: registration[ATTR_MANUFACTURER], + ATTR_MODEL: registration[ATTR_MODEL], + ATTR_OS_VERSION: registration[ATTR_OS_VERSION], + ATTR_SUPPORTS_ENCRYPTION: registration[ATTR_SUPPORTS_ENCRYPTION], + } + + +def savable_state(hass: HomeAssistantType) -> Dict: + """Return a clean object containing things that should be saved.""" + return { + DATA_BINARY_SENSOR: hass.data[DOMAIN][DATA_BINARY_SENSOR], + DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], + DATA_SENSOR: hass.data[DOMAIN][DATA_SENSOR], + } + + +def webhook_response(data, *, registration: Dict, status: int = 200, + headers: Dict = None) -> Response: + """Return a encrypted response if registration supports it.""" + data = json.dumps(data) + + if CONF_SECRET in registration: + keylen, encrypt = setup_encrypt() + + key = registration[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") + data = json.dumps({'encrypted': True, 'encrypted_data': enc_data}) + + return Response(text=data, status=status, content_type='application/json', + headers=headers) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py new file mode 100644 index 00000000000..2ae8f441e52 --- /dev/null +++ b/homeassistant/components/mobile_app/http_api.py @@ -0,0 +1,84 @@ +"""Provides an HTTP API for mobile_app.""" +import uuid +from typing import Dict + +from aiohttp.web import Response, Request + +from homeassistant.auth.util import generate_secret +from homeassistant.components.cloud import (async_create_cloudhook, + async_remote_ui_url, + CloudNotAvailable) +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID) + +from homeassistant.loader import get_component + +from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, + ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, + CONF_REMOTE_UI_URL, CONF_SECRET, + CONF_USER_ID, DOMAIN, ERR_INVALID_COMPONENT, + REGISTRATION_SCHEMA) + +from .helpers import error_response, supports_encryption + + +class RegistrationsView(HomeAssistantView): + """A view that accepts registration requests.""" + + url = '/api/mobile_app/registrations' + name = 'api:mobile_app:register' + + @RequestDataValidator(REGISTRATION_SCHEMA) + async def post(self, request: Request, data: Dict) -> Response: + """Handle the POST request for registration.""" + hass = request.app['hass'] + + if ATTR_APP_COMPONENT in data: + component = get_component(hass, data[ATTR_APP_COMPONENT]) + if component is None: + fmt_str = "{} is not a valid component." + msg = fmt_str.format(data[ATTR_APP_COMPONENT]) + return error_response(ERR_INVALID_COMPONENT, msg) + + if (hasattr(component, 'DEPENDENCIES') is False or + (hasattr(component, 'DEPENDENCIES') and + DOMAIN not in component.DEPENDENCIES)): + fmt_str = "{} is not compatible with mobile_app." + msg = fmt_str.format(data[ATTR_APP_COMPONENT]) + return error_response(ERR_INVALID_COMPONENT, msg) + + webhook_id = generate_secret() + + if hass.components.cloud.async_active_subscription(): + data[CONF_CLOUDHOOK_URL] = \ + await async_create_cloudhook(hass, webhook_id) + + data[ATTR_DEVICE_ID] = str(uuid.uuid4()).replace("-", "") + + data[CONF_WEBHOOK_ID] = webhook_id + + if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): + from nacl.secret import SecretBox + + data[CONF_SECRET] = generate_secret(SecretBox.KEY_SIZE) + + data[CONF_USER_ID] = request['hass_user'].id + + ctx = {'source': 'registration'} + await hass.async_create_task( + hass.config_entries.flow.async_init(DOMAIN, context=ctx, + data=data)) + + remote_ui_url = None + try: + remote_ui_url = async_remote_ui_url(hass) + except CloudNotAvailable: + pass + + return self.json({ + CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL), + CONF_REMOTE_UI_URL: remote_ui_url, + CONF_SECRET: data.get(CONF_SECRET), + CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID], + }, status_code=HTTP_CREATED) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py new file mode 100644 index 00000000000..c6a53ce57ec --- /dev/null +++ b/homeassistant/components/mobile_app/sensor.py @@ -0,0 +1,58 @@ +"""Sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_SENSOR as ENTITY_TYPE, + ATTR_SENSOR_UOM, DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppSensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppSensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppSensor(MobileAppEntity): + """Representation of an mobile app sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._config[ATTR_SENSOR_STATE] + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._config[ATTR_SENSOR_UOM] diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json new file mode 100644 index 00000000000..646151a5229 --- /dev/null +++ b/homeassistant/components/mobile_app/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Mobile App", + "step": { + "confirm": { + "title": "Mobile App", + "description": "Do you want to set up the Mobile App component?" + } + }, + "abort": { + "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps." + } + } +} diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py new file mode 100644 index 00000000000..aafa6046d11 --- /dev/null +++ b/homeassistant/components/mobile_app/webhook.py @@ -0,0 +1,263 @@ +"""Webhook handlers for mobile_app.""" +import logging + +from aiohttp.web import HTTPBadRequest, Response, Request +import voluptuous as vol + +from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, + ATTR_DEV_ID, + DOMAIN as DT_DOMAIN, + SERVICE_SEE as DT_SEE) + +from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, + CONF_WEBHOOK_ID, HTTP_BAD_REQUEST, + HTTP_CREATED) +from homeassistant.core import EventOrigin +from homeassistant.exceptions import (HomeAssistantError, + ServiceNotFound, TemplateError) +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.template import attach +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SPEED, + ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, + ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, + ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, + ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, + CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, + DATA_STORE, DOMAIN, ERR_ENCRYPTION_REQUIRED, + ERR_SENSOR_DUPLICATE_UNIQUE_ID, ERR_SENSOR_NOT_REGISTERED, + SIGNAL_SENSOR_UPDATE, WEBHOOK_PAYLOAD_SCHEMA, + WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_REGISTER_SENSOR, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES) + + +from .helpers import (_decrypt_payload, empty_okay_response, error_response, + registration_context, safe_registration, savable_state, + webhook_response) + + +_LOGGER = logging.getLogger(__name__) + + +async def handle_webhook(hass: HomeAssistantType, webhook_id: str, + request: Request) -> Response: + """Handle webhook callback.""" + if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]: + return Response(status=410) + + headers = {} + + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + + registration = config_entry.data + + try: + req_data = await request.json() + except ValueError: + _LOGGER.warning('Received invalid JSON from mobile_app') + return empty_okay_response(status=HTTP_BAD_REQUEST) + + if (ATTR_WEBHOOK_ENCRYPTED not in req_data and + registration[ATTR_SUPPORTS_ENCRYPTION]): + _LOGGER.warning("Refusing to accept unencrypted webhook from %s", + registration[ATTR_DEVICE_NAME]) + return error_response(ERR_ENCRYPTION_REQUIRED, "Encryption required") + + try: + req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(req_data, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return empty_okay_response() + + webhook_type = req_data[ATTR_WEBHOOK_TYPE] + + webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {}) + + if req_data[ATTR_WEBHOOK_ENCRYPTED]: + enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] + webhook_payload = _decrypt_payload(registration[CONF_SECRET], enc_data) + + if webhook_type not in WEBHOOK_SCHEMAS: + _LOGGER.error('Received invalid webhook type: %s', webhook_type) + return empty_okay_response() + + try: + data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(webhook_payload, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return empty_okay_response(headers=headers) + + context = registration_context(registration) + + if webhook_type == WEBHOOK_TYPE_CALL_SERVICE: + try: + await hass.services.async_call(data[ATTR_DOMAIN], + data[ATTR_SERVICE], + data[ATTR_SERVICE_DATA], + blocking=True, context=context) + # noqa: E722 pylint: disable=broad-except + except (vol.Invalid, ServiceNotFound, Exception) as ex: + _LOGGER.error("Error when calling service during mobile_app " + "webhook (device name: %s): %s", + registration[ATTR_DEVICE_NAME], ex) + raise HTTPBadRequest() + + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_FIRE_EVENT: + event_type = data[ATTR_EVENT_TYPE] + hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA], + EventOrigin.remote, + context=context) + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: + resp = {} + for key, item in data.items(): + try: + tpl = item[ATTR_TEMPLATE] + attach(hass, tpl) + resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES)) + # noqa: E722 pylint: disable=broad-except + except TemplateError as ex: + resp[key] = {"error": str(ex)} + + return webhook_response(resp, registration=registration, + headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: + see_payload = { + ATTR_DEV_ID: registration[ATTR_DEVICE_ID], + ATTR_LOCATION_NAME: data.get(ATTR_LOCATION_NAME), + ATTR_GPS: data.get(ATTR_GPS), + ATTR_GPS_ACCURACY: data.get(ATTR_GPS_ACCURACY), + ATTR_BATTERY: data.get(ATTR_BATTERY), + ATTR_ATTRIBUTES: { + ATTR_SPEED: data.get(ATTR_SPEED), + ATTR_ALTITUDE: data.get(ATTR_ALTITUDE), + ATTR_COURSE: data.get(ATTR_COURSE), + ATTR_VERTICAL_ACCURACY: data.get(ATTR_VERTICAL_ACCURACY), + } + } + + try: + await hass.services.async_call(DT_DOMAIN, + DT_SEE, see_payload, + blocking=True, context=context) + # noqa: E722 pylint: disable=broad-except + except (vol.Invalid, ServiceNotFound, Exception) as ex: + _LOGGER.error("Error when updating location during mobile_app " + "webhook (device name: %s): %s", + registration[ATTR_DEVICE_NAME], ex) + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: + new_registration = {**registration, **data} + + device_registry = await dr.async_get_registry(hass) + + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={ + (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID]) + }, + manufacturer=new_registration[ATTR_MANUFACTURER], + model=new_registration[ATTR_MODEL], + name=new_registration[ATTR_DEVICE_NAME], + sw_version=new_registration[ATTR_OS_VERSION] + ) + + hass.config_entries.async_update_entry(config_entry, + data=new_registration) + + return webhook_response(safe_registration(new_registration), + registration=registration, headers=headers) + + if webhook_type == WEBHOOK_TYPE_REGISTER_SENSOR: + entity_type = data[ATTR_SENSOR_TYPE] + + unique_id = data[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to re-register existing sensor %s!", + unique_id) + return error_response(ERR_SENSOR_DUPLICATE_UNIQUE_ID, + "{} {} already exists!".format(entity_type, + unique_id), + status=409) + + data[CONF_WEBHOOK_ID] = webhook_id + + hass.data[DOMAIN][entity_type][unique_store_key] = data + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) + except HomeAssistantError as ex: + _LOGGER.error("Error registering sensor: %s", ex) + return empty_okay_response() + + register_signal = '{}_{}_register'.format(DOMAIN, + data[ATTR_SENSOR_TYPE]) + async_dispatcher_send(hass, register_signal, data) + + return webhook_response({"status": "registered"}, + registration=registration, status=HTTP_CREATED, + headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_SENSOR_STATES: + resp = {} + for sensor in data: + entity_type = sensor[ATTR_SENSOR_TYPE] + + unique_id = sensor[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key not in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to update non-registered sensor: %s", + unique_store_key) + err_msg = '{} {} is not registered'.format(entity_type, + unique_id) + resp[unique_id] = { + 'success': False, + 'error': { + 'code': ERR_SENSOR_NOT_REGISTERED, + 'message': err_msg + } + } + continue + + entry = hass.data[DOMAIN][entity_type][unique_store_key] + + new_state = {**entry, **sensor} + + hass.data[DOMAIN][entity_type][unique_store_key] = new_state + + safe = savable_state(hass) + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(safe) + except HomeAssistantError as ex: + _LOGGER.error("Error updating mobile_app registration: %s", ex) + return empty_okay_response() + + async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, new_state) + + resp[unique_id] = {"status": "okay"} + + return webhook_response(resp, registration=registration, + headers=headers) diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py new file mode 100644 index 00000000000..7bc1e59d623 --- /dev/null +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -0,0 +1,111 @@ +"""Websocket API for mobile_app.""" +import voluptuous as vol + +from homeassistant.components.cloud import async_delete_cloudhook +from homeassistant.components.websocket_api import (ActiveConnection, + async_register_command, + async_response, + error_message, + result_message, + websocket_command, + ws_require_user) +from homeassistant.components.websocket_api.const import (ERR_INVALID_FORMAT, + ERR_NOT_FOUND, + ERR_UNAUTHORIZED) +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_CONFIG_ENTRIES, + DATA_DELETED_IDS, DATA_STORE, DOMAIN) + +from .helpers import safe_registration, savable_state + + +def register_websocket_handlers(hass: HomeAssistantType) -> bool: + """Register the websocket handlers.""" + async_register_command(hass, websocket_get_user_registrations) + + async_register_command(hass, websocket_delete_registration) + + return True + + +@ws_require_user() +@async_response +@websocket_command({ + vol.Required('type'): 'mobile_app/get_user_registrations', + vol.Optional(CONF_USER_ID): cv.string, +}) +async def websocket_get_user_registrations( + hass: HomeAssistantType, connection: ActiveConnection, + msg: dict) -> None: + """Return all registrations or just registrations for given user ID.""" + user_id = msg.get(CONF_USER_ID, connection.user.id) + + if user_id != connection.user.id and not connection.user.is_admin: + # If user ID is provided and is not current user ID and current user + # isn't an admin user + connection.send_error(msg['id'], ERR_UNAUTHORIZED, "Unauthorized") + return + + user_registrations = [] + + for config_entry in hass.config_entries.async_entries(domain=DOMAIN): + registration = config_entry.data + if connection.user.is_admin or registration[CONF_USER_ID] is user_id: + user_registrations.append(safe_registration(registration)) + + connection.send_message( + result_message(msg['id'], user_registrations)) + + +@ws_require_user() +@async_response +@websocket_command({ + vol.Required('type'): 'mobile_app/delete_registration', + vol.Required(CONF_WEBHOOK_ID): cv.string, +}) +async def websocket_delete_registration(hass: HomeAssistantType, + connection: ActiveConnection, + msg: dict) -> None: + """Delete the registration for the given webhook_id.""" + user = connection.user + + webhook_id = msg.get(CONF_WEBHOOK_ID) + if webhook_id is None: + connection.send_error(msg['id'], ERR_INVALID_FORMAT, + "Webhook ID not provided") + return + + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + + registration = config_entry.data + + if registration is None: + connection.send_error(msg['id'], ERR_NOT_FOUND, + "Webhook ID not found in storage") + return + + if registration[CONF_USER_ID] != user.id and not user.is_admin: + return error_message( + msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') + + await hass.config_entries.async_remove(config_entry.entry_id) + + hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id) + + store = hass.data[DOMAIN][DATA_STORE] + + try: + await store.async_save(savable_state(hass)) + except HomeAssistantError: + return error_message( + msg['id'], 'internal_error', 'Error deleting registration') + + if (CONF_CLOUDHOOK_URL in registration and + "cloud" in hass.config.components): + await async_delete_cloudhook(hass, webhook_id) + + connection.send_message(result_message(msg['id'], 'ok')) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 182e3dc28fa..0500a904cb9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -60,7 +60,9 @@ SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema({ vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, vol.Required(ATTR_UNIT): cv.positive_int, vol.Required(ATTR_ADDRESS): cv.positive_int, - vol.Required(ATTR_VALUE): vol.All(cv.ensure_list, [cv.positive_int]) + vol.Required(ATTR_VALUE): vol.Any( + cv.positive_int, + vol.All(cv.ensure_list, [cv.positive_int])) }) SERVICE_WRITE_COIL_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 0fd9e5a49e7..8713257b47c 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -9,4 +9,4 @@ write_register: fields: address: {description: Address of the holding register to write to., example: 0} unit: {description: Address of the modbus unit., example: 21} - value: {description: Value to write., example: 0} + value: {description: Value (single value or array) to write., example: "0 or [4,0]"} diff --git a/homeassistant/components/mqtt/.translations/th.json b/homeassistant/components/mqtt/.translations/th.json new file mode 100644 index 00000000000..7ea8785af32 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/th.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "broker": { + "data": { + "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + } + }, + "hassio_confirm": { + "data": { + "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e430b1fbc9f..e4d468e2155 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,6 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio +import inspect +from functools import partial, wraps from itertools import groupby import json import logging @@ -25,7 +27,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import Event, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ( @@ -35,6 +37,7 @@ from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) from homeassistant.util.logging import catch_log_exception +from homeassistant.components import websocket_api # Loading the config flow file will register the flow from . import config_flow # noqa pylint: disable=unused-import @@ -263,7 +266,19 @@ MQTT_PUBLISH_SCHEMA = vol.Schema({ # pylint: disable=invalid-name PublishPayloadType = Union[str, bytes, int, float, None] SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None -MessageCallbackType = Callable[[str, SubscribePayloadType, int], None] + + +@attr.s(slots=True, frozen=True) +class Message: + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int) + retain = attr.ib(type=bool) + + +MessageCallbackType = Callable[[Message], None] def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: @@ -303,6 +318,30 @@ def publish_template(hass: HomeAssistantType, topic, payload_template, hass.services.call(DOMAIN, SERVICE_PUBLISH, data) +def wrap_msg_callback( + msg_callback: MessageCallbackType) -> MessageCallbackType: + """Wrap an MQTT message callback to support deprecated signature.""" + # Check for partials to properly determine if coroutine function + check_func = msg_callback + while isinstance(check_func, partial): + check_func = check_func.func + + wrapper_func = None + if asyncio.iscoroutinefunction(check_func): + @wraps(msg_callback) + async def async_wrapper(msg: Any) -> None: + """Catch and log exception.""" + await msg_callback(msg.topic, msg.payload, msg.qos) + wrapper_func = async_wrapper + else: + @wraps(msg_callback) + def wrapper(msg: Any) -> None: + """Catch and log exception.""" + msg_callback(msg.topic, msg.payload, msg.qos) + wrapper_func = wrapper + return wrapper_func + + @bind_hass async def async_subscribe(hass: HomeAssistantType, topic: str, msg_callback: MessageCallbackType, @@ -312,11 +351,25 @@ async def async_subscribe(hass: HomeAssistantType, topic: str, Call the return value to unsubscribe. """ + # Count callback parameters which don't have a default value + non_default = 0 + if msg_callback: + non_default = sum(p.default == inspect.Parameter.empty for _, p in + inspect.signature(msg_callback).parameters.items()) + + wrapped_msg_callback = msg_callback + # If we have 3 paramaters with no default value, wrap the callback + if non_default == 3: + _LOGGER.info( + "Signature of MQTT msg_callback '%s.%s' is deprecated", + inspect.getmodule(msg_callback).__name__, msg_callback.__name__) + wrapped_msg_callback = wrap_msg_callback(msg_callback) + async_remove = await hass.data[DATA_MQTT].async_subscribe( topic, catch_log_exception( - msg_callback, lambda topic, msg, qos: + wrapped_msg_callback, lambda msg: "Exception in {} when handling msg on '{}': '{}'".format( - msg_callback.__name__, topic, msg)), + msg_callback.__name__, msg.topic, msg.payload)), qos, encoding) return async_remove @@ -391,6 +444,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: # This needs a better solution. hass.data[DATA_MQTT_HASS_CONFIG] = config + websocket_api.async_register_command(hass, websocket_subscribe) + if conf is None: # If we have a config entry, setup is done by that config entry. # If there is no config entry, this should fail. @@ -399,14 +454,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: conf = dict(conf) if CONF_EMBEDDED in conf or CONF_BROKER not in conf: - if (conf.get(CONF_PASSWORD) is None and - config.get('http', {}).get('api_password') is not None): - _LOGGER.error( - "Starting from release 0.76, the embedded MQTT broker does not" - " use api_password as default password anymore. Please set" - " password configuration. See https://home-assistant.io/docs/" - "mqtt/broker#embedded-broker for details") - return False broker_config = await _async_setup_server(hass, config) @@ -580,16 +627,6 @@ class Subscription: encoding = attr.ib(type=str, default='utf-8') -@attr.s(slots=True, frozen=True) -class Message: - """MQTT Message.""" - - topic = attr.ib(type=str) - payload = attr.ib(type=PublishPayloadType) - qos = attr.ib(type=int, default=0) - retain = attr.ib(type=bool, default=False) - - class MQTT: """Home Assistant MQTT client.""" @@ -610,6 +647,7 @@ class MQTT: self.keepalive = keepalive self.subscriptions = [] # type: List[Subscription] self.birth_message = birth_message + self.connected = False self._mqttc = None # type: mqtt.Client self._paho_lock = asyncio.Lock(loop=hass.loop) @@ -711,7 +749,10 @@ class MQTT: if any(other.topic == topic for other in self.subscriptions): # Other subscriptions on topic remaining - don't unsubscribe. return - self.hass.async_create_task(self._async_unsubscribe(topic)) + + # Only unsubscribe if currently connected. + if self.connected: + self.hass.async_create_task(self._async_unsubscribe(topic)) return async_remove @@ -751,6 +792,8 @@ class MQTT: self._mqttc.disconnect() return + self.connected = True + # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter('topic') for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), @@ -769,7 +812,8 @@ class MQTT: @callback def _mqtt_handle_message(self, msg) -> None: - _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) + _LOGGER.debug("Received message on %s%s: %s", msg.topic, + " (retained)" if msg.retain else "", msg.payload) for subscription in self.subscriptions: if not _match_topic(subscription.topic, msg.topic): @@ -786,10 +830,13 @@ class MQTT: continue self.hass.async_run_job( - subscription.callback, msg.topic, payload, msg.qos) + subscription.callback, Message(msg.topic, payload, msg.qos, + msg.retain)) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" + self.connected = False + # When disconnected because of calling disconnect() if result_code == 0: return @@ -799,6 +846,7 @@ class MQTT: while True: try: if self._mqttc.reconnect() == 0: + self.connected = True _LOGGER.info("Successfully reconnected to the MQTT server") break except socket.error: @@ -861,19 +909,17 @@ class MqttAttributes(Entity): from .subscription import async_subscribe_topics @callback - def attributes_message_received(topic: str, - payload: SubscribePayloadType, - qos: int) -> None: + def attributes_message_received(msg: Message) -> None: try: - json_dict = json.loads(payload) + json_dict = json.loads(msg.payload) if isinstance(json_dict, dict): self._attributes = json_dict - self.async_schedule_update_ha_state() + self.async_write_ha_state() else: _LOGGER.warning("JSON result was not a dictionary") self._attributes = None except ValueError: - _LOGGER.warning("Erroneous JSON: %s", payload) + _LOGGER.warning("Erroneous JSON: %s", msg.payload) self._attributes = None self._attributes_sub_state = await async_subscribe_topics( @@ -923,16 +969,14 @@ class MqttAvailability(Entity): from .subscription import async_subscribe_topics @callback - def availability_message_received(topic: str, - payload: SubscribePayloadType, - qos: int) -> None: + def availability_message_received(msg: Message) -> None: """Handle a new received MQTT availability message.""" - if payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]: + if msg.payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]: self._available = True - elif payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: + elif msg.payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: self._available = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._availability_sub_state = await async_subscribe_topics( self.hass, self._availability_sub_state, @@ -1048,3 +1092,28 @@ class MqttEntityDeviceInfo(Entity): info['via_hub'] = (DOMAIN, self._device_config[CONF_VIA_HUB]) return info + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'mqtt/subscribe', + vol.Required('topic'): valid_subscribe_topic, +}) +async def websocket_subscribe(hass, connection, msg): + """Subscribe to a MQTT topic.""" + if not connection.user.is_admin: + raise Unauthorized + + async def forward_messages(mqttmsg: Message): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg['id'], { + 'topic': mqttmsg.topic, + 'payload': mqttmsg.payload, + 'qos': mqttmsg.qos, + 'retain': mqttmsg.retain, + })) + + connection.subscriptions[msg['id']] = await async_subscribe( + hass, msg['topic'], forward_messages) + + connection.send_message(websocket_api.result_message(msg['id'])) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 8e1b62414b7..c350b32b4ff 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -28,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) +CONF_CODE_ARM_REQUIRED = 'code_arm_required' CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' @@ -52,6 +53,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) @@ -119,22 +121,23 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @callback - def message_received(topic, payload, qos): + def message_received(msg): """Run when new MQTT message has been received.""" - if payload not in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_PENDING, - STATE_ALARM_TRIGGERED): - _LOGGER.warning("Received unexpected payload: %s", payload) + if msg.payload not in ( + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED): + _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = payload - self.async_schedule_update_ha_state() + self._state = msg.payload + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, @@ -197,7 +200,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming home'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming home'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), @@ -210,7 +214,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming away'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming away'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), @@ -223,7 +228,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming night'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming night'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index cb93712776c..f2a93d06f8e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -117,7 +117,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -130,11 +130,12 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """Switch device off after a delay.""" self._delay_listener = None self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback - def state_message_received(_topic, payload, _qos): + def state_message_received(msg): """Handle a new received MQTT state message.""" + payload = msg.payload value_template = self._config.get(CONF_VALUE_TEMPLATE) if value_template is not None: payload = value_template.async_render_with_possible_json_value( @@ -159,7 +160,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._delay_listener = evt.async_call_later( self.hass, off_delay, off_delay_listener) - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 569d69a9ad8..ca41f3c4225 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -97,14 +97,14 @@ class MqttCamera(MqttDiscoveryUpdate, Camera): config = PLATFORM_SCHEMA(discovery_payload) self._config = config await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" - self._last_image = payload + self._last_image = msg.payload self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 7be47185322..ae847437932 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -231,7 +231,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -288,8 +288,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, qos = self._config.get(CONF_QOS) @callback - def handle_current_temp_received(topic, payload, qos): + def handle_current_temp_received(msg): """Handle current temperature coming via MQTT.""" + payload = msg.payload if CONF_CURRENT_TEMPERATURE_TEMPLATE in self._value_templates: payload =\ self._value_templates[CONF_CURRENT_TEMPERATURE_TEMPLATE].\ @@ -297,7 +298,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._current_temperature = float(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.error("Could not parse temperature from %s", payload) @@ -308,8 +309,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_mode_received(topic, payload, qos): + def handle_mode_received(msg): """Handle receiving mode via MQTT.""" + payload = msg.payload if CONF_MODE_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) @@ -318,7 +320,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid mode: %s", payload) else: self._current_operation = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_MODE_STATE_TOPIC] is not None: topics[CONF_MODE_STATE_TOPIC] = { @@ -327,8 +329,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_temperature_received(topic, payload, qos): + def handle_temperature_received(msg): """Handle target temperature coming via MQTT.""" + payload = msg.payload if CONF_TEMPERATURE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_TEMPERATURE_STATE_TEMPLATE].\ @@ -336,7 +339,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._target_temperature = float(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.error("Could not parse temperature from %s", payload) @@ -347,8 +350,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_fan_mode_received(topic, payload, qos): + def handle_fan_mode_received(msg): """Handle receiving fan mode via MQTT.""" + payload = msg.payload if CONF_FAN_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\ @@ -358,7 +362,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid fan mode: %s", payload) else: self._current_fan_mode = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: topics[CONF_FAN_MODE_STATE_TOPIC] = { @@ -367,8 +371,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_swing_mode_received(topic, payload, qos): + def handle_swing_mode_received(msg): """Handle receiving swing mode via MQTT.""" + payload = msg.payload if CONF_SWING_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\ @@ -378,7 +383,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid swing mode: %s", payload) else: self._current_swing_mode = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: topics[CONF_SWING_MODE_STATE_TOPIC] = { @@ -387,8 +392,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_away_mode_received(topic, payload, qos): + def handle_away_mode_received(msg): """Handle receiving away mode via MQTT.""" + payload = msg.payload payload_on = self._config.get(CONF_PAYLOAD_ON) payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates: @@ -407,7 +413,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.error("Invalid away mode: %s", payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: topics[CONF_AWAY_MODE_STATE_TOPIC] = { @@ -416,8 +422,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_aux_mode_received(topic, payload, qos): + def handle_aux_mode_received(msg): """Handle receiving aux mode via MQTT.""" + payload = msg.payload payload_on = self._config.get(CONF_PAYLOAD_ON) payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AUX_STATE_TEMPLATE in self._value_templates: @@ -435,7 +442,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.error("Invalid aux mode: %s", payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_AUX_STATE_TOPIC] is not None: topics[CONF_AUX_STATE_TOPIC] = { @@ -444,14 +451,15 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_hold_mode_received(topic, payload, qos): + def handle_hold_mode_received(msg): """Handle receiving hold mode via MQTT.""" + payload = msg.payload if CONF_HOLD_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_HOLD_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) self._hold = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_HOLD_STATE_TOPIC] is not None: topics[CONF_HOLD_STATE_TOPIC] = { @@ -558,7 +566,8 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, kwargs.get(ATTR_TEMPERATURE), self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) - self.async_schedule_update_ha_state() + # Always optimistic? + self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" @@ -571,7 +580,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: self._current_swing_mode = swing_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" @@ -584,7 +593,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = fan_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_operation_mode(self, operation_mode) -> None: """Set new operation mode.""" @@ -609,7 +618,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = operation_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def current_swing_mode(self): @@ -632,7 +641,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_away_mode_off(self): """Turn away mode off.""" @@ -645,7 +654,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_hold_mode(self, hold_mode): """Update hold mode on.""" @@ -657,7 +666,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_HOLD_STATE_TOPIC] is None: self._hold = hold_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" @@ -669,7 +678,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_aux_heat_off(self): """Turn auxiliary heater off.""" @@ -681,7 +690,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 829be266b09..37222cbe868 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -195,7 +195,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): self._config = config @@ -216,19 +216,20 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, topics = {} @callback - def tilt_updated(topic, payload, qos): + def tilt_updated(msg): """Handle tilt updates.""" - if (payload.isnumeric() and - (self._config.get(CONF_TILT_MIN) <= int(payload) <= + if (msg.payload.isnumeric() and + (self._config.get(CONF_TILT_MIN) <= int(msg.payload) <= self._config.get(CONF_TILT_MAX))): - level = self.find_percentage_in_range(float(payload)) + level = self.find_percentage_in_range(float(msg.payload)) self._tilt_value = level - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback - def state_message_received(topic, payload, qos): + def state_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) @@ -240,11 +241,12 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.warning("Payload is not True or False: %s", payload) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback - def position_message_received(topic, payload, qos): + def position_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) @@ -259,7 +261,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, "Payload is not integer within range: %s", payload) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_GET_POSITION_TOPIC): topics['get_position_topic'] = { @@ -364,7 +366,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config.get(CONF_POSITION_OPEN), COVER_PAYLOAD) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_close_cover(self, **kwargs): """Move the cover down. @@ -381,7 +383,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config.get(CONF_POSITION_CLOSED), COVER_PAYLOAD) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_stop_cover(self, **kwargs): """Stop the device. @@ -402,7 +404,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config.get(CONF_RETAIN)) if self._tilt_optimistic: self._tilt_value = self._config.get(CONF_TILT_OPEN_POSITION) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" @@ -413,7 +415,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config.get(CONF_RETAIN)) if self._tilt_optimistic: self._tilt_value = self._config.get(CONF_TILT_CLOSED_POSITION) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" @@ -458,7 +460,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = percentage_position == \ self._config.get(CONF_POSITION_CLOSED) self._position = percentage_position - self.async_schedule_update_ha_state() + self.async_write_ha_state() def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 06bd6d771a4..bf55d955ce1 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -31,10 +31,10 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): for dev_id, topic in devices.items(): @callback - def async_message_received(topic, payload, qos, dev_id=dev_id): + def async_message_received(msg, dev_id=dev_id): """Handle received MQTT message.""" hass.async_create_task( - async_see(dev_id=dev_id, location_name=payload)) + async_see(dev_id=dev_id, location_name=msg.payload)) await mqtt.async_subscribe( hass, topic, async_message_received, qos) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 688912070bd..745e54d0ed7 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -200,8 +200,10 @@ def clear_discovery_hash(hass, discovery_hash): async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, config_entry=None) -> bool: """Initialize of MQTT Discovery.""" - async def async_device_message_received(topic, payload, qos): + async def async_device_message_received(msg): """Process the received message.""" + payload = msg.payload + topic = msg.topic match = TOPIC_MATCHER.match(topic) if not match: @@ -235,8 +237,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, key = DEVICE_ABBREVIATIONS.get(key, key) device[key] = device.pop(abbreviated_key) - base = payload.pop(TOPIC_BASE, None) - if base: + if TOPIC_BASE in payload: + base = payload.pop(TOPIC_BASE) for key, value in payload.items(): if isinstance(value, str) and value: if value[0] == TOPIC_BASE and key.endswith('_topic'): diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index d15b236038e..7c9f816eff7 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -158,7 +158,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -212,14 +212,14 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, templates[key] = tpl.async_render_with_possible_json_value @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new received MQTT message.""" - payload = templates[CONF_STATE](payload) + payload = templates[CONF_STATE](msg.payload) if payload == self._payload[STATE_ON]: self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -228,16 +228,16 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': self._config.get(CONF_QOS)} @callback - def speed_received(topic, payload, qos): + def speed_received(msg): """Handle new received MQTT message for the speed.""" - payload = templates[ATTR_SPEED](payload) + payload = templates[ATTR_SPEED](msg.payload) if payload == self._payload[SPEED_LOW]: self._speed = SPEED_LOW elif payload == self._payload[SPEED_MEDIUM]: self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: topics[CONF_SPEED_STATE_TOPIC] = { @@ -247,14 +247,14 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._speed = SPEED_OFF @callback - def oscillation_received(topic, payload, qos): + def oscillation_received(msg): """Handle new received MQTT message for the oscillation.""" - payload = templates[OSCILLATION](payload) + payload = templates[OSCILLATION](msg.payload) if payload == self._payload[OSCILLATE_ON_PAYLOAD]: self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: topics[CONF_OSCILLATION_STATE_TOPIC] = { @@ -360,7 +360,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic_speed: self._speed = speed - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation. @@ -381,7 +381,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic_oscillation: self._oscillation = oscillating - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def unique_id(self): diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 4aee026a2f6..a985a707485 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -174,7 +174,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -254,18 +254,19 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" - payload = templates[CONF_STATE](payload) + payload = templates[CONF_STATE](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", topic) + _LOGGER.debug("Ignoring empty state message from '%s'", + msg.topic) return if payload == self._payload['on']: self._state = True elif payload == self._payload['off']: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -276,19 +277,19 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = last_state.state == STATE_ON @callback - def brightness_received(topic, payload, qos): + def brightness_received(msg): """Handle new MQTT messages for the brightness.""" - payload = templates[CONF_BRIGHTNESS](payload) + payload = templates[CONF_BRIGHTNESS](msg.payload) if not payload: _LOGGER.debug("Ignoring empty brightness message from '%s'", - topic) + msg.topic) return device_value = float(payload) percent_bright = \ device_value / self._config.get(CONF_BRIGHTNESS_SCALE) self._brightness = percent_bright * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: topics[CONF_BRIGHTNESS_STATE_TOPIC] = { @@ -305,11 +306,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._brightness = None @callback - def rgb_received(topic, payload, qos): + def rgb_received(msg): """Handle new MQTT messages for RGB.""" - payload = templates[CONF_RGB](payload) + payload = templates[CONF_RGB](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty rgb message from '%s'", topic) + _LOGGER.debug("Ignoring empty rgb message from '%s'", + msg.topic) return rgb = [int(val) for val in payload.split(',')] @@ -318,7 +320,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, percent_bright = \ float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 self._brightness = percent_bright * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: topics[CONF_RGB_STATE_TOPIC] = { @@ -333,16 +335,16 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._hs = (0, 0) @callback - def color_temp_received(topic, payload, qos): + def color_temp_received(msg): """Handle new MQTT messages for color temperature.""" - payload = templates[CONF_COLOR_TEMP](payload) + payload = templates[CONF_COLOR_TEMP](msg.payload) if not payload: _LOGGER.debug("Ignoring empty color temp message from '%s'", - topic) + msg.topic) return self._color_temp = int(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: topics[CONF_COLOR_TEMP_STATE_TOPIC] = { @@ -359,15 +361,16 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._color_temp = None @callback - def effect_received(topic, payload, qos): + def effect_received(msg): """Handle new MQTT messages for effect.""" - payload = templates[CONF_EFFECT](payload) + payload = templates[CONF_EFFECT](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty effect message from '%s'", topic) + _LOGGER.debug("Ignoring empty effect message from '%s'", + msg.topic) return self._effect = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: topics[CONF_EFFECT_STATE_TOPIC] = { @@ -384,17 +387,17 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._effect = None @callback - def hs_received(topic, payload, qos): + def hs_received(msg): """Handle new MQTT messages for hs color.""" - payload = templates[CONF_HS](payload) + payload = templates[CONF_HS](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty hs message from '%s'", topic) + _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) return try: hs_color = [float(val) for val in payload.split(',', 2)] self._hs = hs_color - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.debug("Failed to parse hs state update: '%s'", payload) @@ -412,19 +415,19 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._hs = (0, 0) @callback - def white_value_received(topic, payload, qos): + def white_value_received(msg): """Handle new MQTT messages for white value.""" - payload = templates[CONF_WHITE_VALUE](payload) + payload = templates[CONF_WHITE_VALUE](msg.payload) if not payload: _LOGGER.debug("Ignoring empty white value message from '%s'", - topic) + msg.topic) return device_value = float(payload) percent_white = \ device_value / self._config.get(CONF_WHITE_VALUE_SCALE) self._white_value = percent_white * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: topics[CONF_WHITE_VALUE_STATE_TOPIC] = { @@ -441,17 +444,17 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._white_value = None @callback - def xy_received(topic, payload, qos): + def xy_received(msg): """Handle new MQTT messages for xy color.""" - payload = templates[CONF_XY](payload) + payload = templates[CONF_XY](msg.payload) if not payload: _LOGGER.debug("Ignoring empty xy-color message from '%s'", - topic) + msg.topic) return xy_color = [float(val) for val in payload.split(',')] self._hs = color_util.color_xy_to_hs(*xy_color) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: topics[CONF_XY_STATE_TOPIC] = { @@ -742,7 +745,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, should_update = True if should_update: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -756,4 +759,4 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 4a97eeea520..12f688afbf7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -136,7 +136,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -201,9 +201,9 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" - values = json.loads(payload) + values = json.loads(msg.payload) if values['state'] == 'ON': self._state = True @@ -276,7 +276,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, except ValueError: _LOGGER.warning("Invalid white value received") - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: self._sub_state = await subscription.async_subscribe_topics( @@ -456,7 +456,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, should_update = True if should_update: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -475,4 +475,4 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 4d086fd73e1..27c1fb00441 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -124,7 +124,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -188,10 +188,10 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" state = self._templates[CONF_STATE_TEMPLATE].\ - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) if state == STATE_ON: self._state = True elif state == STATE_OFF: @@ -203,7 +203,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._brightness = int( self._templates[CONF_BRIGHTNESS_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning("Invalid brightness value received") @@ -212,7 +212,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._color_temp = int( self._templates[CONF_COLOR_TEMP_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning("Invalid color temperature value received") @@ -221,13 +221,13 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: red = int( self._templates[CONF_RED_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) green = int( self._templates[CONF_GREEN_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) blue = int( self._templates[CONF_BLUE_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) self._hs = color_util.color_RGB_to_hs(red, green, blue) except ValueError: _LOGGER.warning("Invalid color value received") @@ -236,21 +236,21 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._white_value = int( self._templates[CONF_WHITE_VALUE_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning('Invalid white value received') if self._templates[CONF_EFFECT_TEMPLATE] is not None: effect = self._templates[CONF_EFFECT_TEMPLATE].\ - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) if effect in self._config.get(CONF_EFFECT_LIST): self._effect = effect else: _LOGGER.warning("Unsupported effect value received") - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: self._sub_state = await subscription.async_subscribe_topics( @@ -400,7 +400,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, ) if self._optimistic: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off. @@ -421,7 +421,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, ) if self._optimistic: - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 82462b8171f..d9adc37d79a 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -111,7 +111,7 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -120,8 +120,9 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, value_template.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" + payload = msg.payload if value_template is not None: payload = value_template.async_render_with_possible_json_value( payload) @@ -130,7 +131,7 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, elif payload == self._config[CONF_PAYLOAD_UNLOCK]: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -185,9 +186,9 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the lock has changed state. self._state = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_unlock(self, **kwargs): """Unlock the device. @@ -200,6 +201,6 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the lock has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 02a4de9cad4..c6ef3344fcf 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -124,7 +124,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -133,8 +133,9 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, template.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" + payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) if expire_after is not None and expire_after > 0: @@ -169,7 +170,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, payload = template.async_render_with_possible_json_value( payload, self._state) self._state = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, @@ -189,7 +190,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """Triggered when value is expired.""" self._expiration_trigger = None self._state = None - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index c9f8c880573..de7da6b7249 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -121,7 +121,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -143,8 +143,9 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, template.hass = self.hass @callback - def state_message_received(topic, payload, qos): + def state_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) @@ -153,7 +154,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, elif payload == self._state_off: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -222,7 +223,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -238,4 +239,4 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 3d53f32c6f6..eb7e78b6254 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -264,7 +264,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_added_to_hass(self): """Subscribe MQTT events.""" @@ -284,45 +284,45 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, tpl.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT message.""" - if topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \ + if msg.topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \ self._templates[CONF_BATTERY_LEVEL_TEMPLATE]: battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if battery_level is not None: self._battery_level = int(battery_level) - if topic == self._state_topics[CONF_CHARGING_TOPIC] and \ + if msg.topic == self._state_topics[CONF_CHARGING_TOPIC] and \ self._templates[CONF_CHARGING_TEMPLATE]: charging = self._templates[CONF_CHARGING_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if charging is not None: self._charging = cv.boolean(charging) - if topic == self._state_topics[CONF_CLEANING_TOPIC] and \ + if msg.topic == self._state_topics[CONF_CLEANING_TOPIC] and \ self._templates[CONF_CLEANING_TEMPLATE]: cleaning = self._templates[CONF_CLEANING_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if cleaning is not None: self._cleaning = cv.boolean(cleaning) - if topic == self._state_topics[CONF_DOCKED_TOPIC] and \ + if msg.topic == self._state_topics[CONF_DOCKED_TOPIC] and \ self._templates[CONF_DOCKED_TEMPLATE]: docked = self._templates[CONF_DOCKED_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if docked is not None: self._docked = cv.boolean(docked) - if topic == self._state_topics[CONF_ERROR_TOPIC] and \ + if msg.topic == self._state_topics[CONF_ERROR_TOPIC] and \ self._templates[CONF_ERROR_TEMPLATE]: error = self._templates[CONF_ERROR_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if error is not None: self._error = cv.string(error) @@ -338,15 +338,15 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: self._status = "Stopped" - if topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \ + if msg.topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \ self._templates[CONF_FAN_SPEED_TEMPLATE]: fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if fan_speed is not None: self._fan_speed = fan_speed - self.async_schedule_update_ha_state() + self.async_write_ha_state() topics_list = {topic for topic in self._state_topics.values() if topic} self._sub_state = await subscription.async_subscribe_topics( @@ -434,7 +434,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_TURN_ON], self._qos, self._retain) self._status = 'Cleaning' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the vacuum off.""" @@ -445,7 +445,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_TURN_OFF], self._qos, self._retain) self._status = 'Turning Off' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_stop(self, **kwargs): """Stop the vacuum.""" @@ -456,7 +456,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_STOP], self._qos, self._retain) self._status = 'Stopping the current task' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_clean_spot(self, **kwargs): """Perform a spot clean-up.""" @@ -467,7 +467,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_CLEAN_SPOT], self._qos, self._retain) self._status = "Cleaning spot" - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_locate(self, **kwargs): """Locate the vacuum (usually by playing a song).""" @@ -478,7 +478,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_LOCATE], self._qos, self._retain) self._status = "Hi, I'm over here!" - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_start_pause(self, **kwargs): """Start, pause or resume the cleaning task.""" @@ -489,7 +489,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_START_PAUSE], self._qos, self._retain) self._status = 'Pausing/Resuming cleaning...' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_return_to_base(self, **kwargs): """Tell the vacuum to return to its dock.""" @@ -500,7 +500,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_RETURN_TO_BASE], self._qos, self._retain) self._status = 'Returning home...' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" @@ -512,7 +512,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, mqtt.async_publish(self.hass, self._set_fan_speed_topic, fan_speed, self._qos, self._retain) self._status = "Setting fan to {}...".format(fan_speed) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_send_command(self, command, params=None, **kwargs): """Send a command to a vacuum cleaner.""" @@ -522,4 +522,4 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, mqtt.async_publish(self.hass, self._send_command_topic, command, self._qos, self._retain) self._status = "Sending command {}...".format(command) - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 6e545d19fe2..fb6a94f1870 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -74,9 +74,9 @@ def async_setup(hass, config): # Process events from a remote server that are received on a queue. @callback - def _event_receiver(topic, payload, qos): + def _event_receiver(msg): """Receive events published by and fire them on this hass instance.""" - event = json.loads(payload) + event = json.loads(msg.payload) event_type = event.get('event_type') event_data = event.get('event_data') diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index d4a52655d19..62ea20cbb91 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -98,9 +98,9 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): def sub_callback(topic, sub_cb, qos): """Call MQTT subscribe function.""" @callback - def internal_callback(*args): + def internal_callback(msg): """Call callback.""" - sub_cb(*args) + sub_cb(msg.topic, msg.payload, msg.qos) hass.async_create_task( mqtt.async_subscribe(topic, internal_callback, qos)) diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index ce6d5da2b4c..9acd47b6238 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,8 @@ """Support for MySensors sensors.""" from homeassistant.components import mysensors from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT, + ENERGY_KILO_WATT_HOUR) SENSORS = { 'V_TEMP': [None, 'mdi:thermometer'], @@ -12,8 +13,8 @@ SENSORS = { 'V_WEIGHT': ['kg', 'mdi:weight-kilogram'], 'V_DISTANCE': ['m', 'mdi:ruler'], 'V_IMPEDANCE': ['ohm', None], - 'V_WATT': ['W', None], - 'V_KWH': ['kWh', None], + 'V_WATT': [POWER_WATT, None], + 'V_KWH': [ENERGY_KILO_WATT_HOUR, None], 'V_FLOW': ['m', None], 'V_VOLUME': ['m³', None], 'V_VOLTAGE': ['V', 'mdi:flash'], diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index c7175a0c3c7..653ade806ec 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -1,16 +1,19 @@ """Support for Ness D8X/D16X devices.""" -from collections import namedtuple +import datetime import logging +from collections import namedtuple import voluptuous as vol from homeassistant.components.binary_sensor import DEVICE_CLASSES -from homeassistant.const import ATTR_CODE, ATTR_STATE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import (ATTR_CODE, ATTR_STATE, + EVENT_HOMEASSISTANT_STOP, + CONF_SCAN_INTERVAL) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['nessclient==0.9.13'] +REQUIREMENTS = ['nessclient==0.9.14'] _LOGGER = logging.getLogger(__name__) @@ -25,6 +28,7 @@ CONF_ZONE_TYPE = 'type' CONF_ZONE_ID = 'id' ATTR_OUTPUT_ID = 'output_id' DEFAULT_ZONES = [] +DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=1) SIGNAL_ZONE_CHANGED = 'ness_alarm.zone_changed' SIGNAL_ARMING_STATE_CHANGED = 'ness_alarm.arming_state_changed' @@ -42,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_DEVICE_HOST): cv.string, vol.Required(CONF_DEVICE_PORT): cv.port, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_ZONES, default=DEFAULT_ZONES): vol.All(cv.ensure_list, [ZONE_SCHEMA]), }), @@ -67,8 +73,10 @@ async def async_setup(hass, config): zones = conf[CONF_ZONES] host = conf[CONF_DEVICE_HOST] port = conf[CONF_DEVICE_PORT] + scan_interval = conf[CONF_SCAN_INTERVAL] - client = Client(host=host, port=port, loop=hass.loop) + client = Client(host=host, port=port, loop=hass.loop, + update_interval=scan_interval.total_seconds()) hass.data[DATA_NESS] = client async def _close(event): diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 1e16f2d3e05..2d8b06dd466 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,77 +1,148 @@ """Support for Netatmo Smart thermostats.""" import logging from datetime import timedelta + import voluptuous as vol -from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +import homeassistant.helpers.config_validation as cv from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( - STATE_HEAT, STATE_IDLE, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) + STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, STATE_MANUAL, STATE_AUTO, + STATE_ECO, STATE_COOL) +from homeassistant.const import ( + STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME) from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['netatmo'] _LOGGER = logging.getLogger(__name__) -CONF_RELAY = 'relay' -CONF_THERMOSTAT = 'thermostat' +CONF_HOMES = 'homes' +CONF_ROOMS = 'rooms' -DEFAULT_AWAY_TEMPERATURE = 14 -# # The default offset is 2 hours (when you use the thermostat itself) -DEFAULT_TIME_OFFSET = 7200 -# # Return cached results if last scan was less then this time ago -# # NetAtmo Data is uploaded to server every hour -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +HOME_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]) +}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_RELAY): cv.string, - vol.Optional(CONF_THERMOSTAT, default=[]): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HOMES): vol.All(cv.ensure_list, [HOME_CONFIG_SCHEMA]) }) +STATE_NETATMO_SCHEDULE = 'schedule' +STATE_NETATMO_HG = 'hg' +STATE_NETATMO_MAX = 'max' +STATE_NETATMO_AWAY = 'away' +STATE_NETATMO_OFF = STATE_OFF +STATE_NETATMO_MANUAL = STATE_MANUAL + +DICT_NETATMO_TO_HA = { + STATE_NETATMO_SCHEDULE: STATE_AUTO, + STATE_NETATMO_HG: STATE_COOL, + STATE_NETATMO_MAX: STATE_HEAT, + STATE_NETATMO_AWAY: STATE_ECO, + STATE_NETATMO_OFF: STATE_OFF, + STATE_NETATMO_MANUAL: STATE_MANUAL +} + +DICT_HA_TO_NETATMO = { + STATE_AUTO: STATE_NETATMO_SCHEDULE, + STATE_COOL: STATE_NETATMO_HG, + STATE_HEAT: STATE_NETATMO_MAX, + STATE_ECO: STATE_NETATMO_AWAY, + STATE_OFF: STATE_NETATMO_OFF, + STATE_MANUAL: STATE_NETATMO_MANUAL +} + SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE) +NA_THERM = 'NATherm1' +NA_VALVE = 'NRV' + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the NetAtmo Thermostat.""" netatmo = hass.components.netatmo - device = config.get(CONF_RELAY) import pyatmo + homes_conf = config.get(CONF_HOMES) try: - data = ThermostatData(netatmo.NETATMO_AUTH, device) - for module_name in data.get_module_names(): - if CONF_THERMOSTAT in config: - if config[CONF_THERMOSTAT] != [] and \ - module_name not in config[CONF_THERMOSTAT]: - continue - add_entities([NetatmoThermostat(data, module_name)], True) + home_data = HomeData(netatmo.NETATMO_AUTH) except pyatmo.NoDevice: - return None + return + + homes = [] + rooms = {} + if homes_conf is not None: + for home_conf in homes_conf: + home = home_conf[CONF_NAME] + if home_conf[CONF_ROOMS] != []: + rooms[home] = home_conf[CONF_ROOMS] + homes.append(home) + else: + homes = home_data.get_home_names() + + devices = [] + for home in homes: + _LOGGER.debug("Setting up %s ...", home) + try: + room_data = ThermostatData(netatmo.NETATMO_AUTH, home) + except pyatmo.NoDevice: + continue + for room_id in room_data.get_room_ids(): + room_name = room_data.homedata.rooms[home][room_id]['name'] + _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id) + if home in rooms and room_name not in rooms[home]: + _LOGGER.debug("Excluding %s ...", room_name) + continue + _LOGGER.debug("Adding devices for room %s (%s) ...", + room_name, room_id) + devices.append(NetatmoThermostat(room_data, room_id)) + add_entities(devices, True) class NetatmoThermostat(ClimateDevice): """Representation a Netatmo thermostat.""" - def __init__(self, data, module_name, away_temp=None): + def __init__(self, data, room_id): """Initialize the sensor.""" self._data = data self._state = None - self._name = module_name + self._room_id = room_id + room_name = self._data.homedata.rooms[self._data.home][room_id]['name'] + self._name = 'netatmo_{}'.format(room_name) self._target_temperature = None self._away = None + self._module_type = self._data.room_status[room_id]['module_type'] + if self._module_type == NA_VALVE: + self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY], + DICT_NETATMO_TO_HA[STATE_NETATMO_HG]] + self._support_flags = SUPPORT_FLAGS + elif self._module_type == NA_THERM: + self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY], + DICT_NETATMO_TO_HA[STATE_NETATMO_HG], + DICT_NETATMO_TO_HA[STATE_NETATMO_MAX], + DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]] + self._support_flags = SUPPORT_FLAGS | SUPPORT_ON_OFF + self._operation_mode = None + self.update_without_throttle = False @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def name(self): - """Return the name of the sensor.""" + """Return the name of the thermostat.""" return self._name @property @@ -82,90 +153,256 @@ class NetatmoThermostat(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._data.current_temperature + return self._data.room_status[self._room_id]['current_temperature'] @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temperature + return self._data.room_status[self._room_id]['target_temperature'] @property def current_operation(self): """Return the current state of the thermostat.""" - state = self._data.thermostatdata.relay_cmd - if state == 0: - return STATE_IDLE - if state == 100: - return STATE_HEAT + return self._operation_mode + + @property + def operation_list(self): + """Return the operation modes list.""" + return self._operation_list + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + module_type = self._data.room_status[self._room_id]['module_type'] + if module_type not in (NA_THERM, NA_VALVE): + return {} + state_attributes = { + "home_id": self._data.homedata.gethomeId(self._data.home), + "room_id": self._room_id, + "setpoint_default_duration": self._data.setpoint_duration, + "away_temperature": self._data.away_temperature, + "hg_temperature": self._data.hg_temperature, + "operation_mode": self._operation_mode, + "module_type": module_type, + "module_id": self._data.room_status[self._room_id]['module_id'] + } + if module_type == NA_THERM: + state_attributes["boiler_status"] = self._data.boilerstatus + elif module_type == NA_VALVE: + state_attributes["heating_power_request"] = \ + self._data.room_status[self._room_id]['heating_power_request'] + return state_attributes @property def is_away_mode_on(self): """Return true if away mode is on.""" return self._away + @property + def is_on(self): + """Return true if on.""" + return self.target_temperature > 0 + def turn_away_mode_on(self): """Turn away on.""" - mode = "away" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = True + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY]) def turn_away_mode_off(self): """Turn away off.""" - mode = "program" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = False + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE]) + + def turn_off(self): + """Turn Netatmo off.""" + _LOGGER.debug("Switching off ...") + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]) + self.update_without_throttle = True + self.schedule_update_ha_state() + + def turn_on(self): + """Turn Netatmo on.""" + _LOGGER.debug("Switching on ...") + _LOGGER.debug("Setting temperature first to %d ...", + self._data.hg_temperature) + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, STATE_NETATMO_MANUAL, self._data.hg_temperature) + _LOGGER.debug("Setting operation mode to schedule ...") + self._data.homestatus.setThermmode( + self._data.homedata.gethomeId(self._data.home), + STATE_NETATMO_SCHEDULE) + self.update_without_throttle = True + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" + if not self.is_on: + self.turn_on() + if operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_MAX], + DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]]: + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, DICT_HA_TO_NETATMO[operation_mode]) + elif operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_HG], + DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY]]: + self._data.homestatus.setThermmode( + self._data.homedata.gethomeId(self._data.home), + DICT_HA_TO_NETATMO[operation_mode]) + self.update_without_throttle = True + self.schedule_update_ha_state() def set_temperature(self, **kwargs): """Set new target temperature for 2 hours.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: return - mode = "manual" - self._data.thermostatdata.setthermpoint( - mode, temperature, DEFAULT_TIME_OFFSET) - self._target_temperature = temperature - self._away = False + mode = STATE_NETATMO_MANUAL + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, DICT_HA_TO_NETATMO[mode], temp) + self.update_without_throttle = True + self.schedule_update_ha_state() - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from NetAtmo API and updates the states.""" - self._data.update() - self._target_temperature = self._data.thermostatdata.setpoint_temp - self._away = self._data.setpoint_mode == 'away' + try: + if self.update_without_throttle: + self._data.update(no_throttle=True) + self.update_without_throttle = False + else: + self._data.update() + except AttributeError: + _LOGGER.error("NetatmoThermostat::update() " + "got exception.") + return + self._target_temperature = \ + self._data.room_status[self._room_id]['target_temperature'] + self._operation_mode = DICT_NETATMO_TO_HA[ + self._data.room_status[self._room_id]['setpoint_mode']] + self._away = self._operation_mode == DICT_NETATMO_TO_HA[ + STATE_NETATMO_AWAY] + + +class HomeData: + """Representation Netatmo homes.""" + + def __init__(self, auth, home=None): + """Initialize the HomeData object.""" + self.auth = auth + self.homedata = None + self.home_names = [] + self.room_names = [] + self.schedules = [] + self.home = home + self.home_id = None + + def get_home_names(self): + """Get all the home names returned by NetAtmo API.""" + self.setup() + if self.homedata is None: + return [] + for home in self.homedata.homes: + if 'therm_schedules' in self.homedata.homes[home] and 'modules' \ + in self.homedata.homes[home]: + self.home_names.append(self.homedata.homes[home]['name']) + return self.home_names + + def setup(self): + """Retrieve HomeData by NetAtmo API.""" + import pyatmo + try: + self.homedata = pyatmo.HomeData(self.auth) + self.home_id = self.homedata.gethomeId(self.home) + except TypeError: + _LOGGER.error("Error when getting home data.") + except pyatmo.NoDevice: + _LOGGER.debug("No thermostat devices available.") class ThermostatData: """Get the latest data from Netatmo.""" - def __init__(self, auth, device=None): + def __init__(self, auth, home=None): """Initialize the data object.""" self.auth = auth - self.thermostatdata = None - self.module_names = [] - self.device = device - self.current_temperature = None - self.target_temperature = None - self.setpoint_mode = None + self.homedata = None + self.homestatus = None + self.room_ids = [] + self.room_status = {} + self.schedules = [] + self.home = home + self.away_temperature = None + self.hg_temperature = None + self.boilerstatus = None + self.setpoint_duration = None + self.home_id = None - def get_module_names(self): + def get_room_ids(self): """Return all module available on the API as a list.""" - self.update() - if not self.device: - for device in self.thermostatdata.modules: - for module in self.thermostatdata.modules[device].values(): - self.module_names.append(module['module_name']) - else: - for module in self.thermostatdata.modules[self.device].values(): - self.module_names.append(module['module_name']) - return self.module_names + if not self.setup(): + return [] + for key in self.homestatus.rooms: + self.room_ids.append(key) + return self.room_ids + + def setup(self): + """Retrieve HomeData and HomeStatus by NetAtmo API.""" + import pyatmo + try: + self.homedata = pyatmo.HomeData(self.auth) + self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) + self.home_id = self.homedata.gethomeId(self.home) + self.update() + except TypeError: + _LOGGER.error("ThermostatData::setup() got error.") + return False + return True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the NetAtmo API to update the data.""" import pyatmo - self.thermostatdata = pyatmo.ThermostatData(self.auth) - self.target_temperature = self.thermostatdata.setpoint_temp - self.setpoint_mode = self.thermostatdata.setpoint_mode - self.current_temperature = self.thermostatdata.temp + try: + self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) + except TypeError: + _LOGGER.error("Error when getting homestatus.") + return + _LOGGER.debug("Following is the debugging output for homestatus:") + _LOGGER.debug(self.homestatus.rawData) + for key in self.homestatus.rooms: + roomstatus = {} + homestatus_room = self.homestatus.rooms[key] + homedata_room = self.homedata.rooms[self.home][key] + roomstatus['roomID'] = homestatus_room['id'] + roomstatus['roomname'] = homedata_room['name'] + roomstatus['target_temperature'] = \ + homestatus_room['therm_setpoint_temperature'] + roomstatus['setpoint_mode'] = \ + homestatus_room['therm_setpoint_mode'] + roomstatus['current_temperature'] = \ + homestatus_room['therm_measured_temperature'] + roomstatus['module_type'] = \ + self.homestatus.thermostatType(self.home, key) + roomstatus['module_id'] = None + roomstatus['heating_status'] = None + roomstatus['heating_power_request'] = None + for module_id in homedata_room['module_ids']: + if self.homedata.modules[self.home][module_id]['type'] == \ + NA_THERM or roomstatus['module_id'] is None: + roomstatus['module_id'] = module_id + if roomstatus['module_type'] == NA_THERM: + self.boilerstatus = self.homestatus.boilerStatus( + rid=roomstatus['module_id']) + roomstatus['heating_status'] = self.boilerstatus + elif roomstatus['module_type'] == NA_VALVE: + roomstatus['heating_power_request'] = \ + homestatus_room['heating_power_request'] + roomstatus['heating_status'] = \ + roomstatus['heating_power_request'] > 0 + if self.boilerstatus is not None: + roomstatus['heating_status'] = \ + self.boilerstatus and roomstatus['heating_status'] + self.room_status[key] = roomstatus + self.away_temperature = self.homestatus.getAwaytemp(self.home) + self.hg_temperature = self.homestatus.getHgtemp(self.home) + self.setpoint_duration = self.homedata.setpoint_duration[self.home] diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 28fedf6434d..17df1ba8f5a 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -39,8 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the AWS Lambda notification service.""" - context_str = json.dumps({'hass': hass.config.as_dict(), - 'custom': config[CONF_CONTEXT]}, cls=JSONEncoder) + context_str = json.dumps({'custom': config[CONF_CONTEXT]}, cls=JSONEncoder) context_b64 = base64.b64encode(context_str.encode('utf-8')) context = context_b64.decode('utf-8') diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index dd35f986f78..df25045c6ec 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -112,7 +112,8 @@ class RestNotificationService(BaseNotificationService): response = requests.get(self._resource, headers=self._headers, params=data, timeout=10) - if response.status_code not in (200, 201): + success_codes = (200, 201, 202, 203, 204, 205, 206, 207, 208, 226) + if response.status_code not in success_codes: _LOGGER.exception( "Error sending message. Response %d: %s:", response.status_code, response.reason) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 231a17455d1..e72dcbbed36 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -18,33 +18,35 @@ REQUIREMENTS = ['sendgrid==5.6.0'] _LOGGER = logging.getLogger(__name__) +CONF_SENDER_NAME = 'sender_name' + +DEFAULT_SENDER_NAME = 'Home Assistant' + # pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_SENDER): vol.Email(), vol.Required(CONF_RECIPIENT): vol.Email(), + vol.Optional(CONF_SENDER_NAME, default=DEFAULT_SENDER_NAME): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the SendGrid notification service.""" - api_key = config.get(CONF_API_KEY) - sender = config.get(CONF_SENDER) - recipient = config.get(CONF_RECIPIENT) - - return SendgridNotificationService(api_key, sender, recipient) + return SendgridNotificationService(config) class SendgridNotificationService(BaseNotificationService): """Implementation the notification service for email via Sendgrid.""" - def __init__(self, api_key, sender, recipient): + def __init__(self, config): """Initialize the service.""" from sendgrid import SendGridAPIClient - self.api_key = api_key - self.sender = sender - self.recipient = recipient + self.api_key = config[CONF_API_KEY] + self.sender = config[CONF_SENDER] + self.sender_name = config[CONF_SENDER_NAME] + self.recipient = config[CONF_RECIPIENT] self._sg = SendGridAPIClient(apikey=self.api_key) @@ -64,7 +66,8 @@ class SendgridNotificationService(BaseNotificationService): } ], "from": { - "email": self.sender + "email": self.sender, + "name": self.sender_name }, "content": [ { diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 6bbe546dcb1..f8885962ee7 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -4,7 +4,7 @@ from homeassistant.loader import bind_hass from .const import DOMAIN, STEP_USER, STEPS -DEPENDENCIES = ['http'] +DEPENDENCIES = ['auth', 'http'] STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 804589200fa..d9631b77a20 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -74,6 +74,7 @@ class UserOnboardingView(_BaseOnboardingView): vol.Required('name'): str, vol.Required('username'): str, vol.Required('password'): str, + vol.Required('client_id'): str, })) async def post(self, request, data): """Return the manifest.json.""" @@ -98,8 +99,17 @@ class UserOnboardingView(_BaseOnboardingView): await hass.components.person.async_create_person( data['name'], user_id=user.id ) + await self._async_mark_done(hass) + # Return an authorization code to allow fetching tokens. + auth_code = hass.components.auth.create_auth_code( + data['client_id'], user + ) + return self.json({ + 'auth_code': auth_code + }) + @callback def _async_get_hass_provider(hass): diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 52383366c4d..5533beb2fae 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import Entity from .config_flow import configured_instances from .const import DOMAIN -REQUIREMENTS = ['pyopenuv==1.0.4'] +REQUIREMENTS = ['pyopenuv==1.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/owntracks/.translations/cs.json b/homeassistant/components/owntracks/.translations/cs.json new file mode 100644 index 00000000000..25738b7618e --- /dev/null +++ b/homeassistant/components/owntracks/.translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "\n\n V syst\u00e9mu Android otev\u0159ete aplikaci [OwnTracks]({android_url}) a p\u0159ejd\u011bte na p\u0159edvolby - > p\u0159ipojen\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: Private HTTP \n - Hostitel: {webhook_url} \n - Identifikace: \n - U\u017eivatelsk\u00e9 jm\u00e9no: ` ' \n - ID za\u0159\u00edzen\u00ed: ` ' \n\n V aplikaci iOS otev\u0159ete [aplikaci OwnTracks]({ios_url}), klepn\u011bte na ikonu (i) vlevo naho\u0159e - > nastaven\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: HTTP \n - URL: {webhook_url} \n - Zapn\u011bte ov\u011b\u0159ov\u00e1n\u00ed \n - ID u\u017eivatele: ` ' \n\n {secret} \n \n V\u00edce informac\u00ed naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu OwnTracks?", + "title": "Nastavit OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index c0d3d152270..df6b815e4c5 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -99,16 +99,16 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]['context'] - async def async_handle_mqtt_message(topic, payload, qos): + async def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(payload) + message = json.loads(msg.payload) except ValueError: # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) + _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) return - message['topic'] = topic + message['topic'] = msg.topic hass.helpers.dispatcher.async_dispatcher_send( DOMAIN, hass, context, message) diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json index ebd2b88b10e..16d1bddbaf7 100644 --- a/homeassistant/components/point/.translations/zh-Hans.json +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", - "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6" + "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", + "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002" }, "error": { "follow_link": "\u8bf7\u5728\u70b9\u51fb\u63d0\u4ea4\u524d\u6309\u7167\u94fe\u63a5\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1", - "no_token": "\u672a\u7ecfMinut\u9a8c\u8bc1" + "no_token": "\u672a\u7ecf Minut \u9a8c\u8bc1" }, "step": { "auth": { diff --git a/homeassistant/components/ps4/.translations/es.json b/homeassistant/components/ps4/.translations/es.json index 41cbd28492a..65798ba4d0c 100644 --- a/homeassistant/components/ps4/.translations/es.json +++ b/homeassistant/components/ps4/.translations/es.json @@ -3,9 +3,12 @@ "abort": { "credential_error": "Error al obtener las credenciales.", "devices_configured": "Todos los dispositivos encontrados ya est\u00e1n configurados.", - "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red." + "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red.", + "port_987_bind_error": "No se pudo unir al puerto 987.", + "port_997_bind_error": "No se pudo unir al puerto 997." }, "error": { + "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.", "not_ready": "PlayStation 4 no est\u00e1 encendido o conectado a la red." }, "step": { diff --git a/homeassistant/components/ps4/.translations/fr.json b/homeassistant/components/ps4/.translations/fr.json new file mode 100644 index 00000000000..d7983448417 --- /dev/null +++ b/homeassistant/components/ps4/.translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "Adresse IP", + "name": "Nom", + "region": "R\u00e9gion" + }, + "description": "Entrez vos informations PlayStation 4. Pour \"Code PIN\", acc\u00e9dez \u00e0 \"Param\u00e8tres\" sur votre console PlayStation 4. Ensuite, acc\u00e9dez \u00e0 \"Param\u00e8tres de connexion de l'application mobile\" et s\u00e9lectionnez \"Ajouter un p\u00e9riph\u00e9rique\". Entrez le code PIN qui est affich\u00e9.", + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json new file mode 100644 index 00000000000..3dcadef20eb --- /dev/null +++ b/homeassistant/components/ps4/.translations/nl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "credential_error": "Fout bij ophalen van inloggegevens.", + "devices_configured": "Alle gevonden apparaten zijn al geconfigureerd.", + "no_devices_found": "Geen PlayStation 4 apparaten gevonden op het netwerk.", + "port_987_bind_error": "Kan niet binden aan poort 987.", + "port_997_bind_error": "Kan niet binden aan poort 997." + }, + "error": { + "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.", + "not_ready": "PlayStation 4 staat niet aan of is niet verbonden met een netwerk." + }, + "step": { + "creds": { + "description": "Aanmeldingsgegevens zijn nodig. Druk op 'Verzenden' en vervolgens in de PS4-app voor het 2e scherm, vernieuw apparaten en selecteer Home Assistant om door te gaan.", + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "IP-adres", + "name": "Naam", + "region": "Regio" + }, + "description": "Voer je PlayStation 4 informatie in. Voor 'PIN', blader naar 'Instellingen' op je PlayStation 4. Blader dan naar 'Mobiele App verbindingsinstellingen' en kies 'Apparaat toevoegen'. Voer de weergegeven PIN-code in.", + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/th.json b/homeassistant/components/ps4/.translations/th.json new file mode 100644 index 00000000000..a48089bfdd6 --- /dev/null +++ b/homeassistant/components/ps4/.translations/th.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48 IP", + "name": "\u0e0a\u0e37\u0e48\u0e2d", + "region": "\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04" + }, + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/zh-Hans.json b/homeassistant/components/ps4/.translations/zh-Hans.json new file mode 100644 index 00000000000..8c975e8170c --- /dev/null +++ b/homeassistant/components/ps4/.translations/zh-Hans.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "credential_error": "\u83b7\u53d6\u51ed\u636e\u65f6\u51fa\u9519\u3002", + "devices_configured": "\u6240\u6709\u53d1\u73b0\u7684\u8bbe\u5907\u90fd\u5df2\u914d\u7f6e\u5b8c\u6210\u3002", + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 PlayStation 4 \u8bbe\u5907\u3002", + "port_987_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 987\u3002", + "port_997_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 997\u3002" + }, + "error": { + "login_failed": "\u65e0\u6cd5\u4e0e PlayStation 4 \u914d\u5bf9\u3002\u8bf7\u786e\u8ba4 PIN \u662f\u5426\u6b63\u786e\u3002", + "not_ready": "PlayStation 4 \u672a\u5f00\u673a\u6216\u672a\u8fde\u63a5\u5230\u7f51\u7edc\u3002" + }, + "step": { + "creds": { + "description": "\u9700\u8981\u51ed\u636e\u3002\u8bf7\u70b9\u51fb\u201c\u63d0\u4ea4\u201d\u7136\u540e\u5728 PS4 \u7b2c\u4e8c\u5c4f\u5e55\u5e94\u7528\u7a0b\u5e8f\u4e2d\u5237\u65b0\u8bbe\u5907\u5e76\u9009\u62e9\u201cHome-Assistant\u201d\u8bbe\u5907\u4ee5\u7ee7\u7eed\u3002", + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "IP \u5730\u5740", + "name": "\u540d\u79f0", + "region": "\u5730\u533a" + }, + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 51260f5d86e..087d3f89f80 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -11,7 +11,7 @@ from homeassistant.components.ps4.const import DOMAIN # noqa: pylint: disable=u _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyps4-homeassistant==0.3.0'] +REQUIREMENTS = ['pyps4-homeassistant==0.4.8'] async def async_setup(hass, config): diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 3557c3fd930..d000ed1f7e7 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -37,10 +37,6 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle a user config flow.""" - # Abort if device is configured. - if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason='devices_configured') - # Check if able to bind to ports: UDP 987, TCP 997. ports = PORT_MSG.keys() failed = await self.hass.async_add_executor_job( @@ -48,6 +44,9 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): if failed in ports: reason = PORT_MSG[failed] return self.async_abort(reason=reason) + # Skip Creds Step if a device is configured. + if self.hass.config_entries.async_entries(DOMAIN): + return await self.async_step_link() return await self.async_step_creds() async def async_step_creds(self, user_input=None): @@ -78,6 +77,18 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): device_list = [ device['host-ip'] for device in devices] + # If entry exists check that devices found aren't configured. + if self.hass.config_entries.async_entries(DOMAIN): + for entry in self.hass.config_entries.async_entries(DOMAIN): + conf_devices = entry.data['devices'] + for c_device in conf_devices: + if c_device['host'] in device_list: + # Remove configured device from search list. + device_list.remove(c_device['host']) + # If list is empty then all devices are configured. + if not device_list: + return self.async_abort(reason='devices_configured') + # Login to PS4 with user data. if user_input is not None: self.region = user_input[CONF_REGION] diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index bf7be1bbf91..74dce515d9d 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -133,6 +133,7 @@ class PS4Device(MediaPlayerDevice): self._retry = 0 self._info = None self._unique_id = None + self._power_on = False async def async_added_to_hass(self): """Subscribe PS4 events.""" @@ -144,6 +145,7 @@ class PS4Device(MediaPlayerDevice): try: status = self._ps4.get_status() if self._info is None: + # Add entity to registry self.get_device_info(status) self._games = self.load_games() if self._games is not None: @@ -153,6 +155,17 @@ class PS4Device(MediaPlayerDevice): if status is not None: self._retry = 0 if status.get('status') == 'Ok': + # Check if only 1 device in Hass. + if len(self.hass.data[PS4_DATA].devices) == 1: + # Enable keep alive feature for PS4 Connection. + # Only 1 device is supported, Since have to use port 997. + self._ps4.keep_alive = True + else: + self._ps4.keep_alive = False + if self._power_on: + # Auto Login after Turn On. + self._ps4.open() + self._power_on = False title_id = status.get('running-app-titleid') name = status.get('running-app-name') if title_id and name is not None: @@ -268,6 +281,10 @@ class PS4Device(MediaPlayerDevice): } self._unique_id = status['host-id'] + async def async_will_remove_from_hass(self): + """Remove Entity from Hass.""" + self.hass.data[PS4_DATA].devices.remove(self) + @property def device_info(self): """Return information about the device.""" @@ -346,15 +363,16 @@ class PS4Device(MediaPlayerDevice): def turn_on(self): """Turn on the media player.""" + self._power_on = True self._ps4.wakeup() def media_pause(self): """Send keypress ps to return to menu.""" - self._ps4.remote_control('ps') + self.send_remote_control('ps') def media_stop(self): """Send keypress ps to return to menu.""" - self._ps4.remote_control('ps') + self.send_remote_control('ps') def select_source(self, source): """Select input source.""" @@ -369,4 +387,8 @@ class PS4Device(MediaPlayerDevice): def send_command(self, command): """Send Button Command.""" + self.send_remote_control(command) + + def send_remote_control(self, command): + """Send RC command.""" self._ps4.remote_control(command) diff --git a/homeassistant/components/rainmachine/.translations/th.json b/homeassistant/components/rainmachine/.translations/th.json new file mode 100644 index 00000000000..4b250fbc134 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 0591af8acfa..6d986fa5c67 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -19,7 +19,7 @@ from .config_flow import configured_instances from .const import ( DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN) -REQUIREMENTS = ['regenmaschine==1.1.0'] +REQUIREMENTS = ['regenmaschine==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -31,6 +31,7 @@ ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN) CONF_CONTROLLERS = 'controllers' CONF_PROGRAM_ID = 'program_id' +CONF_SECONDS = 'seconds' CONF_ZONE_ID = 'zone_id' CONF_ZONE_RUN_TIME = 'zone_run_time' @@ -73,6 +74,18 @@ SENSOR_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [vol.In(SENSORS)]) }) +SERVICE_ALTER_PROGRAM = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_ALTER_ZONE = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, +}) + +SERVICE_PAUSE_WATERING = vol.Schema({ + vol.Required(CONF_SECONDS): cv.positive_int, +}) + SERVICE_START_PROGRAM_SCHEMA = vol.Schema({ vol.Required(CONF_PROGRAM_ID): cv.positive_int, }) @@ -184,6 +197,32 @@ async def async_setup_entry(hass, config_entry): refresh, timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + async def disable_program(service): + """Disable a program.""" + await rainmachine.client.programs.disable( + service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def disable_zone(service): + """Disable a zone.""" + await rainmachine.client.zones.disable(service.data[CONF_ZONE_ID]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + + async def enable_program(service): + """Enable a program.""" + await rainmachine.client.programs.enable(service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def enable_zone(service): + """Enable a zone.""" + await rainmachine.client.zones.enable(service.data[CONF_ZONE_ID]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + + async def pause_watering(service): + """Pause watering for a set number of seconds.""" + await rainmachine.client.watering.pause_all(service.data[CONF_SECONDS]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + async def start_program(service): """Start a particular program.""" await rainmachine.client.programs.start(service.data[CONF_PROGRAM_ID]) @@ -210,12 +249,23 @@ async def async_setup_entry(hass, config_entry): await rainmachine.client.zones.stop(service.data[CONF_ZONE_ID]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + async def unpause_watering(service): + """Unpause watering.""" + await rainmachine.client.watering.unpause_all() + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + for service, method, schema in [ + ('disable_program', disable_program, SERVICE_ALTER_PROGRAM), + ('disable_zone', disable_zone, SERVICE_ALTER_ZONE), + ('enable_program', enable_program, SERVICE_ALTER_PROGRAM), + ('enable_zone', enable_zone, SERVICE_ALTER_ZONE), + ('pause_watering', pause_watering, SERVICE_PAUSE_WATERING), ('start_program', start_program, SERVICE_START_PROGRAM_SCHEMA), ('start_zone', start_zone, SERVICE_START_ZONE_SCHEMA), ('stop_all', stop_all, {}), ('stop_program', stop_program, SERVICE_STOP_PROGRAM_SCHEMA), - ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA) + ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA), + ('unpause_watering', unpause_watering, {}), ]: hass.services.async_register(DOMAIN, service, method, schema=schema) diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index a8c77628c8f..288161968de 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -1,6 +1,36 @@ # Describes the format for available RainMachine services --- +disable_program: + description: Disable a program. + fields: + program_id: + description: The program to disable. + example: 3 +disable_zone: + description: Disable a zone. + fields: + zone_id: + description: The zone to disable. + example: 3 +enable_program: + description: Enable a program. + fields: + program_id: + description: The program to enable. + example: 3 +enable_zone: + description: Enable a zone. + fields: + zone_id: + description: The zone to enable. + example: 3 +pause_watering: + description: Pause all watering for a number of seconds. + fields: + seconds: + description: The number of seconds to pause. + example: 30 start_program: description: Start a program. fields: @@ -30,3 +60,5 @@ stop_zone: zone_id: description: The zone to stop. example: 3 +unpause_watering: + description: Unpause all watering. diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index b48cc0a1e14..e3a1ddab912 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.rainmachine/ """ import logging +from datetime import datetime from homeassistant.components.rainmachine import ( DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, @@ -19,6 +20,7 @@ DEPENDENCIES = ['rainmachine'] _LOGGER = logging.getLogger(__name__) +ATTR_NEXT_RUN = 'next_run' ATTR_AREA = 'area' ATTR_CS_ON = 'cs_on' ATTR_CURRENT_CYCLE = 'current_cycle' @@ -111,20 +113,12 @@ async def async_setup_entry(hass, entry, async_add_entities): entities = [] - programs = await rainmachine.client.programs.all() + programs = await rainmachine.client.programs.all(include_inactive=True) for program in programs: - if not program.get('active'): - continue - - _LOGGER.debug('Adding program: %s', program) entities.append(RainMachineProgram(rainmachine, program)) - zones = await rainmachine.client.zones.all() + zones = await rainmachine.client.zones.all(include_inactive=True) for zone in zones: - if not zone.get('active'): - continue - - _LOGGER.debug('Adding zone: %s', zone) entities.append( RainMachineZone( rainmachine, zone, rainmachine.default_zone_runtime)) @@ -144,16 +138,16 @@ class RainMachineSwitch(RainMachineEntity, SwitchDevice): self._rainmachine_entity_id = obj['uid'] self._switch_type = switch_type + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._obj.get('active')) + @property def icon(self) -> str: """Return the icon.""" return 'mdi:water' - @property - def is_enabled(self) -> bool: - """Return whether the entity is enabled.""" - return self._obj.get('active') - @property def unique_id(self) -> str: """Return a unique, HASS-friendly identifier for this entity.""" @@ -222,8 +216,17 @@ class RainMachineProgram(RainMachineSwitch): self._obj = await self.rainmachine.client.programs.get( self._rainmachine_entity_id) + try: + next_run = datetime.strptime( + '{0} {1}'.format( + self._obj['nextRun'], self._obj['startTime']), + '%Y-%m-%d %H:%M').isoformat() + except ValueError: + next_run = None + self._attrs.update({ ATTR_ID: self._obj['uid'], + ATTR_NEXT_RUN: next_run, ATTR_SOAK: self._obj.get('soak'), ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get('status')], ATTR_ZONES: ', '.join(z['name'] for z in self.zones) @@ -297,13 +300,13 @@ class RainMachineZone(RainMachineSwitch): ATTR_CURRENT_CYCLE: self._obj.get('cycle'), ATTR_FIELD_CAPACITY: - self._properties_json.get('waterSense') - .get('fieldCapacity'), + self._properties_json.get('waterSense').get( + 'fieldCapacity'), ATTR_NO_CYCLES: self._obj.get('noOfCycles'), ATTR_PRECIP_RATE: - self._properties_json.get('waterSense') - .get('precipitationRate'), + self._properties_json.get('waterSense').get( + 'precipitationRate'), ATTR_RESTRICTIONS: self._obj.get('restriction'), ATTR_SLOPE: diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 825f402aef2..972862e7a9c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -220,6 +220,15 @@ def _apply_update(engine, new_version, old_version): _create_index(engine, "states", "ix_states_context_user_id") elif new_version == 7: _create_index(engine, "states", "ix_states_entity_id") + elif new_version == 8: + # Pending migration, want to group a few. + pass + # _add_columns(engine, "events", [ + # 'context_parent_id CHARACTER(36)', + # ]) + # _add_columns(engine, "states", [ + # 'context_parent_id CHARACTER(36)', + # ]) else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index d1be17b83d5..bea2b12b370 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -34,16 +34,20 @@ class Events(Base): # type: ignore created = Column(DateTime(timezone=True), default=datetime.utcnow) context_id = Column(String(36), index=True) context_user_id = Column(String(36), index=True) + # context_parent_id = Column(String(36), index=True) @staticmethod def from_event(event): """Create an event database object from a native event.""" - return Events(event_type=event.event_type, - event_data=json.dumps(event.data, cls=JSONEncoder), - origin=str(event.origin), - time_fired=event.time_fired, - context_id=event.context.id, - context_user_id=event.context.user_id) + return Events( + event_type=event.event_type, + event_data=json.dumps(event.data, cls=JSONEncoder), + origin=str(event.origin), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + # context_parent_id=event.context.parent_id, + ) def to_native(self): """Convert to a natve HA Event.""" @@ -81,6 +85,7 @@ class States(Base): # type: ignore created = Column(DateTime(timezone=True), default=datetime.utcnow) context_id = Column(String(36), index=True) context_user_id = Column(String(36), index=True) + # context_parent_id = Column(String(36), index=True) __table_args__ = ( # Used for fetching the state of entities at a specific time @@ -99,6 +104,7 @@ class States(Base): # type: ignore entity_id=entity_id, context_id=event.context.id, context_user_id=event.context.user_id, + # context_parent_id=event.context.parent_id, ) # State got deleted diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 449f910fda9..c96cfe78dd2 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -20,12 +20,16 @@ def session_scope(*, hass=None, session=None): if session is None: raise RuntimeError('Session required') + need_rollback = False try: yield session - session.commit() + if session.transaction: + need_rollback = True + session.commit() except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error executing query: %s", err) - session.rollback() + if need_rollback: + session.rollback() raise finally: session.close() diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index b7923596039..de79adc9f0e 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -23,6 +23,7 @@ ATTR_COMMAND = 'command' ATTR_DEVICE = 'device' ATTR_NUM_REPEATS = 'num_repeats' ATTR_DELAY_SECS = 'delay_secs' +ATTR_HOLD_SECS = 'hold_secs' DOMAIN = 'remote' DEPENDENCIES = ['group'] @@ -40,6 +41,7 @@ SERVICE_SYNC = 'sync' DEFAULT_NUM_REPEATS = 1 DEFAULT_DELAY_SECS = 0.4 +DEFAULT_HOLD_SECS = 0 REMOTE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, @@ -55,6 +57,7 @@ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ vol.Optional( ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int, vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float), + vol.Optional(ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS): vol.Coerce(float), }) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 1fb4b048707..62615f28714 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -42,6 +42,10 @@ send_command: delay_secs: description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used. example: '0.75' + hold_secs: + description: An optional value that specifies that number of seconds you want to have it held before the release is send. If not specified, the release will be send immediately after the press. + example: '2.5' + harmony_sync: description: Syncs the remote's configuration. diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index a7b703ef2ab..411f0538bde 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -6,7 +6,8 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, ATTR_STATE, CONF_DEVICE, CONF_DEVICES, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, + POWER_WATT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -40,8 +41,8 @@ DATA_TYPES = OrderedDict([ ('Barometer', ''), ('Wind direction', ''), ('Rain rate', ''), - ('Energy usage', 'W'), - ('Total usage', 'W'), + ('Energy usage', POWER_WATT), + ('Total usage', POWER_WATT), ('Sound', ''), ('Sensor Status', ''), ('Counter value', ''), diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index bff365a079f..93f157cd5ec 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -1,19 +1,15 @@ """Support for Satel Integra devices.""" -import asyncio import logging - import voluptuous as vol +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['satel_integra==0.2.0'] +REQUIREMENTS = ['satel_integra==0.3.2'] DEFAULT_ALARM_NAME = 'satel_integra' DEFAULT_PORT = 7094 @@ -76,7 +72,7 @@ async def async_setup(hass, config): port = conf.get(CONF_DEVICE_PORT) partition = conf.get(CONF_DEVICE_PARTITION) - from satel_integra.satel_integra import AsyncSatel, AlarmState + from satel_integra.satel_integra import AsyncSatel controller = AsyncSatel(host, port, hass.loop, zones, outputs, partition) @@ -96,41 +92,19 @@ async def async_setup(hass, config): conf, conf.get(CONF_ARM_HOME_MODE)) - task_control_panel = hass.async_create_task( + hass.async_create_task( async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) - task_zones = hass.async_create_task( + hass.async_create_task( async_load_platform(hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones, CONF_OUTPUTS: outputs}, config) ) - await asyncio.wait([task_control_panel, task_zones], loop=hass.loop) - @callback - def alarm_status_update_callback(status): + def alarm_status_update_callback(): """Send status update received from alarm to home assistant.""" - _LOGGER.debug("Alarm status callback, status: %s", status) - hass_alarm_status = STATE_ALARM_DISARMED - - if status == AlarmState.ARMED_MODE0: - hass_alarm_status = STATE_ALARM_ARMED_AWAY - - elif status in [ - AlarmState.ARMED_MODE0, - AlarmState.ARMED_MODE1, - AlarmState.ARMED_MODE2, - AlarmState.ARMED_MODE3 - ]: - hass_alarm_status = STATE_ALARM_ARMED_HOME - - elif status in [AlarmState.TRIGGERED, AlarmState.TRIGGERED_FIRE]: - hass_alarm_status = STATE_ALARM_TRIGGERED - - elif status == AlarmState.DISARMED: - hass_alarm_status = STATE_ALARM_DISARMED - - _LOGGER.debug("Sending hass_alarm_status: %s...", hass_alarm_status) - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, hass_alarm_status) + _LOGGER.debug("Sending request to update panel state") + async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE) @callback def zones_update_callback(status): diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 360acdb2497..d2d9f473051 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -1,12 +1,19 @@ """Support for Satel Integra alarm, using ETHM module.""" +import asyncio import logging +from collections import OrderedDict import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.satel_integra import ( - CONF_ARM_HOME_MODE, DATA_SATEL, SIGNAL_PANEL_MESSAGE) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + CONF_ARM_HOME_MODE, CONF_DEVICE_PARTITION, DATA_SATEL, + SIGNAL_PANEL_MESSAGE) + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['satel_integra'] @@ -19,32 +26,73 @@ async def async_setup_platform( return device = SatelIntegraAlarmPanel( - "Alarm Panel", discovery_info.get(CONF_ARM_HOME_MODE)) + "Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE), + discovery_info.get(CONF_DEVICE_PARTITION)) + async_add_entities([device]) class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self, name, arm_home_mode): + def __init__(self, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._name = name self._state = None self._arm_home_mode = arm_home_mode + self._partition_id = partition_id async def async_added_to_hass(self): - """Register callbacks.""" + """Update alarm status and register callbacks for future updates.""" + _LOGGER.debug("Starts listening for panel messages") + self._update_alarm_status() async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status) @callback - def _message_callback(self, message): - """Handle received messages.""" - if message != self._state: - self._state = message + def _update_alarm_status(self): + """Handle alarm status update.""" + state = self._read_alarm_state() + _LOGGER.debug("Got status update, current status: %s", state) + if state != self._state: + self._state = state self.async_schedule_update_ha_state() else: - _LOGGER.warning("Ignoring alarm status message, same state") + _LOGGER.debug("Ignoring alarm status message, same state") + + def _read_alarm_state(self): + """Read current status of the alarm and translate it into HA status.""" + from satel_integra.satel_integra import AlarmState + + # Default - disarmed: + hass_alarm_status = STATE_ALARM_DISARMED + + satel_controller = self.hass.data[DATA_SATEL] + if not satel_controller.connected: + return None + + state_map = OrderedDict([ + (AlarmState.TRIGGERED, STATE_ALARM_TRIGGERED), + (AlarmState.TRIGGERED_FIRE, STATE_ALARM_TRIGGERED), + (AlarmState.ARMED_MODE3, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE2, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE1, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE0, STATE_ALARM_ARMED_AWAY), + (AlarmState.EXIT_COUNTDOWN_OVER_10, STATE_ALARM_PENDING), + (AlarmState.EXIT_COUNTDOWN_UNDER_10, STATE_ALARM_PENDING) + ]) + _LOGGER.debug("State map of Satel: %s", + satel_controller.partition_states) + + for satel_state, ha_state in state_map.items(): + if satel_state in satel_controller.partition_states and\ + self._partition_id in\ + satel_controller.partition_states[satel_state]: + hass_alarm_status = ha_state + break + + return hass_alarm_status @property def name(self): @@ -68,16 +116,32 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): async def async_alarm_disarm(self, code=None): """Send disarm command.""" - if code: - await self.hass.data[DATA_SATEL].disarm(code) + if not code: + _LOGGER.debug("Code was empty or None") + return + + clear_alarm_necessary = self._state == STATE_ALARM_TRIGGERED + + _LOGGER.debug("Disarming, self._state: %s", self._state) + + await self.hass.data[DATA_SATEL].disarm(code) + + if clear_alarm_necessary: + # Wait 1s before clearing the alarm + await asyncio.sleep(1) + await self.hass.data[DATA_SATEL].clear_alarm(code) async def async_alarm_arm_away(self, code=None): """Send arm away command.""" + _LOGGER.debug("Arming away") + if code: await self.hass.data[DATA_SATEL].arm(code) async def async_alarm_arm_home(self, code=None): """Send arm home command.""" + _LOGGER.debug("Arming home") + if code: await self.hass.data[DATA_SATEL].arm( code, self._arm_home_mode) diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 34ced628712..0384ff37f14 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -2,15 +2,13 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.satel_integra import (CONF_ZONES, - CONF_OUTPUTS, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONES_UPDATED, - SIGNAL_OUTPUTS_UPDATED) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + CONF_OUTPUTS, CONF_ZONE_NAME, CONF_ZONE_TYPE, CONF_ZONES, DATA_SATEL, + SIGNAL_OUTPUTS_UPDATED, SIGNAL_ZONES_UPDATED) + DEPENDENCIES = ['satel_integra'] _LOGGER = logging.getLogger(__name__) @@ -58,6 +56,18 @@ class SatelIntegraBinarySensor(BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" + if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED: + if self._device_number in\ + self.hass.data[DATA_SATEL].violated_outputs: + self._state = 1 + else: + self._state = 0 + else: + if self._device_number in\ + self.hass.data[DATA_SATEL].violated_zones: + self._state = 1 + else: + self._state = 0 async_dispatcher_connect( self.hass, self._react_to_signal, self._devices_updated) diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 8a7934bd694..35eedabd58a 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -68,7 +68,7 @@ async def async_setup(hass, config): async def async_handle_scene_service(service): """Handle calls to the switch services.""" - target_scenes = component.async_extract_from_service(service) + target_scenes = await component.async_extract_from_service(service) tasks = [scene.async_activate() for scene in target_scenes] if tasks: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index fceedb57428..873a18120ac 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -74,20 +74,21 @@ async def async_setup(hass, config): # We could turn on script directly here, but we only want to offer # one way to do it. Otherwise no easy way to detect invocations. var = service.data.get(ATTR_VARIABLES) - for script in component.async_extract_from_service(service): + for script in await component.async_extract_from_service(service): await hass.services.async_call(DOMAIN, script.object_id, var, context=service.context) async def turn_off_service(service): """Cancel a script.""" # Stopping a script is ok to be done in parallel - await asyncio.wait( - [script.async_turn_off() for script - in component.async_extract_from_service(service)], loop=hass.loop) + await asyncio.wait([ + script.async_turn_off() for script + in await component.async_extract_from_service(service) + ], loop=hass.loop) async def toggle_service(service): """Toggle a script.""" - for script in component.async_extract_from_service(service): + for script in await component.async_extract_from_service(service): await script.async_toggle(context=service.context) hass.services.async_register(DOMAIN, SERVICE_RELOAD, reload_service, diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 11c45991400..97771200bcd 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -5,9 +5,9 @@ import voluptuous as vol from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['sense_energy==0.6.0'] +REQUIREMENTS = ['sense_energy==0.7.0'] _LOGGER = logging.getLogger(__name__) @@ -27,22 +27,24 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Sense sensor.""" - from sense_energy import Senseable, SenseAuthenticationException + from sense_energy import ASyncSenseable, SenseAuthenticationException username = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN][CONF_PASSWORD] timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = Senseable( + hass.data[SENSE_DATA] = ASyncSenseable( api_timeout=timeout, wss_timeout=timeout) - hass.data[SENSE_DATA].authenticate(username, password) hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE + await hass.data[SENSE_DATA].authenticate(username, password) except SenseAuthenticationException: _LOGGER.error("Could not authenticate with sense server") return False - load_platform(hass, 'sensor', DOMAIN, {}, config) - load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + hass.async_create_task( + async_load_platform(hass, 'sensor', DOMAIN, None, config)) + hass.async_create_task( + async_load_platform(hass, 'binary_sensor', DOMAIN, None, config)) return True diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 80fb8f2634d..545aaa8ae7b 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -49,17 +49,15 @@ MDI_ICONS = { } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Sense binary sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] - sense_devices = data.get_discovered_device_data() + sense_devices = await data.get_discovered_device_data() devices = [SenseDevice(data, device) for device in sense_devices if device['tags']['DeviceListAllowed'] == 'true'] - add_entities(devices) + async_add_entities(devices) def sense_to_mdi(sense_icon): @@ -103,11 +101,11 @@ class SenseDevice(BinarySensorDevice): """Return the device class of the binary sensor.""" return BIN_SENSOR_CLASS - def update(self): + async def async_update(self): """Retrieve latest state.""" from sense_energy.sense_api import SenseAPITimeoutException try: - self._data.get_realtime() + await self._data.update_realtime() except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") return diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 2995b860e5b..4810ebf1958 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from homeassistant.components.sense import SENSE_DATA +from homeassistant.const import POWER_WATT, ENERGY_KILO_WATT_HOUR from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -44,21 +45,19 @@ SENSOR_TYPES = { SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Sense sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) - def update_trends(): + async def update_trends(): """Update the daily power usage.""" - data.update_trend_data() + await data.update_trend_data() - def update_active(): + async def update_active(): """Update the active power usage.""" - data.get_realtime() + await data.update_realtime() devices = [] for typ in SENSOR_TYPES.values(): @@ -73,7 +72,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices.append(Sense( data, name, sensor_type, is_production, update_call)) - add_entities(devices) + async_add_entities(devices) class Sense(Entity): @@ -90,9 +89,9 @@ class Sense(Entity): self._state = None if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = 'W' + self._unit_of_measurement = POWER_WATT else: - self._unit_of_measurement = 'kWh' + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): @@ -114,11 +113,11 @@ class Sense(Entity): """Icon to use in the frontend, if any.""" return ICON - def update(self): + async def async_update(self): """Get the latest data, update state.""" from sense_energy import SenseAPITimeoutException try: - self.update_sensor() + await self.update_sensor() except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") return diff --git a/homeassistant/components/sensor/.translations/season.af.json b/homeassistant/components/sensor/.translations/season.af.json new file mode 100644 index 00000000000..0dbe4a131ee --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.af.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Herfs", + "spring": "Lente", + "summer": "Somer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.eu.json b/homeassistant/components/sensor/.translations/season.eu.json new file mode 100644 index 00000000000..f226d920043 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.eu.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Udazkeneko", + "spring": "Spring", + "summer": "Uda", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 2b79e4c3a9a..95825f4ca13 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -61,7 +61,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the ARWN platform.""" @callback - def async_sensor_event_received(topic, payload, qos): + def async_sensor_event_received(msg): """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -74,8 +74,8 @@ async def async_setup_platform(hass, config, async_add_entities, This lets us dynamically incorporate sensors without any configuration on our side. """ - event = json.loads(payload) - sensors = discover_sensors(topic, event) + event = json.loads(msg.payload) + sensors = discover_sensors(msg.topic, event) if not sensors: return diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index 2cbf9a6d691..41584b2561f 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -1,9 +1,4 @@ -""" -Support for information about the German train system. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.deutsche_bahn/ -""" +"""Support for information about the German train system.""" from datetime import timedelta import logging @@ -14,7 +9,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['schiene==0.22'] +REQUIREMENTS = ['schiene==0.23'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/discogs.py b/homeassistant/components/sensor/discogs.py index ecbd6d9cab1..8cdc89a540e 100644 --- a/homeassistant/components/sensor/discogs.py +++ b/homeassistant/components/sensor/discogs.py @@ -40,17 +40,17 @@ SENSOR_RANDOM_RECORD_TYPE = 'random_record' SENSORS = { SENSOR_COLLECTION_TYPE: { 'name': 'Collection', - 'icon': 'mdi:album', - 'unit_of_measurement': 'records' + 'icon': ICON_RECORD, + 'unit_of_measurement': UNIT_RECORDS }, SENSOR_WANTLIST_TYPE: { 'name': 'Wantlist', - 'icon': 'mdi:album', - 'unit_of_measurement': 'records' + 'icon': ICON_RECORD, + 'unit_of_measurement': UNIT_RECORDS }, SENSOR_RANDOM_RECORD_TYPE: { 'name': 'Random Record', - 'icon': 'mdi:record_player', + 'icon': ICON_PLAYER, 'unit_of_measurement': None }, } diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 1bb7b44cab6..6319a68b0c8 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -350,7 +350,9 @@ class DerivativeDSMREntity(DSMREntity): else: # Recalculate the rate diff = current_reading - self._previous_reading - self._state = diff + timediff = timestamp - self._previous_timestamp + total_seconds = timediff.total_seconds() + self._state = round(float(diff) / total_seconds * 3600, 3) self._previous_reading = current_reading self._previous_timestamp = timestamp diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 54666c74f96..b3c40b4fa25 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -10,7 +10,8 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_CURRENCY +from homeassistant.const import (CONF_CURRENCY, POWER_WATT, + ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -34,11 +35,11 @@ DEFAULT_PERIOD = 'year' DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { - CONF_INSTANT: ['Energy Usage', 'W'], - CONF_AMOUNT: ['Energy Consumed', 'kWh'], + CONF_INSTANT: ['Energy Usage', POWER_WATT], + CONF_AMOUNT: ['Energy Consumed', ENERGY_KILO_WATT_HOUR], CONF_BUDGET: ['Energy Budget', None], CONF_COST: ['Energy Cost', None], - CONF_CURRENT_VALUES: ['Per-Device Usage', 'W'] + CONF_CURRENT_VALUES: ['Per-Device Usage', POWER_WATT] } TYPES_SCHEMA = vol.In(SENSOR_TYPES) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 8e750a8d5e1..b03164a30d4 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -10,7 +10,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME) +from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -27,7 +27,7 @@ ICON = 'mdi:gauge' SCAN_INTERVAL = timedelta(seconds=60) -UNIT_OF_MEASUREMENT = 'W' +UNIT_OF_MEASUREMENT = POWER_WATT PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ACCESS_TOKEN): cv.string, diff --git a/homeassistant/components/sensor/emoncms.py b/homeassistant/components/sensor/emoncms.py index 7546224d4c5..5d619878d98 100644 --- a/homeassistant/components/sensor/emoncms.py +++ b/homeassistant/components/sensor/emoncms.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_API_KEY, CONF_URL, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, - CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN) + CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN, POWER_WATT) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template from homeassistant.util import Throttle @@ -34,7 +34,7 @@ CONF_ONLY_INCLUDE_FEEDID = 'include_only_feed_id' CONF_SENSOR_NAMES = 'sensor_names' DECIMALS = 2 -DEFAULT_UNIT = 'W' +DEFAULT_UNIT = POWER_WATT MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) diff --git a/homeassistant/components/sensor/enphase_envoy.py b/homeassistant/components/sensor/enphase_envoy.py index 4bbf7eec01b..1bfee88d41c 100644 --- a/homeassistant/components/sensor/enphase_envoy.py +++ b/homeassistant/components/sensor/enphase_envoy.py @@ -11,14 +11,15 @@ import voluptuous as vol from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -from homeassistant.const import (CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS, POWER_WATT) REQUIREMENTS = ['envoy_reader==0.3'] _LOGGER = logging.getLogger(__name__) SENSORS = { - "production": ("Envoy Current Energy Production", 'W'), + "production": ("Envoy Current Energy Production", POWER_WATT), "daily_production": ("Envoy Today's Energy Production", "Wh"), "seven_days_production": ("Envoy Last Seven Days Energy Production", "Wh"), "lifetime_production": ("Envoy Lifetime Energy Production", "Wh"), diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py index a1a306f36e0..8dfb330cf5c 100644 --- a/homeassistant/components/sensor/flunearyou.py +++ b/homeassistant/components/sensor/flunearyou.py @@ -18,7 +18,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyflunearyou==1.0.1'] +REQUIREMENTS = ['pyflunearyou==1.0.3'] _LOGGER = logging.getLogger(__name__) ATTR_CITY = 'city' diff --git a/homeassistant/components/sensor/greeneye_monitor.py b/homeassistant/components/sensor/greeneye_monitor.py index 3793ea7846c..4dee9d69b42 100644 --- a/homeassistant/components/sensor/greeneye_monitor.py +++ b/homeassistant/components/sensor/greeneye_monitor.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/sensors.greeneye_monitor_temperature/ """ import logging -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT +from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT from homeassistant.helpers.entity import Entity from ..greeneye_monitor import ( @@ -33,7 +33,7 @@ DEPENDENCIES = ['greeneye_monitor'] DATA_PULSES = 'pulses' DATA_WATT_SECONDS = 'watt_seconds' -UNIT_WATTS = 'W' +UNIT_WATTS = POWER_WATT COUNTER_ICON = 'mdi:counter' CURRENT_SENSOR_ICON = 'mdi:flash' diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index cb75e69b919..5f0fd9e01ad 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, + CONF_USERNAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR, CONF_NAME, CONF_MONITORED_VARIABLES, TEMP_CELSIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -24,7 +24,7 @@ REQUIREMENTS = ['pyhydroquebec==2.2.2'] _LOGGER = logging.getLogger(__name__) -KILOWATT_HOUR = 'kWh' # type: str +KILOWATT_HOUR = ENERGY_KILO_WATT_HOUR PRICE = 'CAD' # type: str DAYS = 'days' # type: str CONF_CONTRACT = 'contract' # type: str diff --git a/homeassistant/components/sensor/linky.py b/homeassistant/components/sensor/linky.py index 8130961bfc0..46e7ed92f45 100644 --- a/homeassistant/components/sensor/linky.py +++ b/homeassistant/components/sensor/linky.py @@ -10,7 +10,8 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, + ENERGY_KILO_WATT_HOUR) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -71,7 +72,7 @@ class LinkySensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return 'kWh' + return ENERGY_KILO_WATT_HOUR @Throttle(SCAN_INTERVAL) def update(self): diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 0f2362ca33c..2ee94249b4c 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyloopenergy==0.0.18'] +REQUIREMENTS = ['pyloopenergy==0.1.0'] CONF_ELEC = 'electricity' CONF_GAS = 'gas' diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py index 6b2b3776557..e2dda136244 100644 --- a/homeassistant/components/sensor/mopar.py +++ b/homeassistant/components/sensor/mopar.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['motorparts==1.0.2'] +REQUIREMENTS = ['motorparts==1.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index b52f039281c..36f99719da4 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -90,16 +90,16 @@ class MQTTRoomSensor(Entity): self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" try: - data = MQTT_PAYLOAD(payload) + data = MQTT_PAYLOAD(msg.payload) except vol.MultipleInvalid as error: _LOGGER.debug( "Skipping update because of malformatted data: %s", error) return - device = _parse_update_data(topic, data) + device = _parse_update_data(msg.topic, data) if device.get(CONF_DEVICE_ID) == self._device_id: if self._distance is None or self._updated is None: update_state(**device) diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index dc517a0c50d..6a6eea02005 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -26,6 +26,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) CONF_DATA_GROUP = 'data_group' CONF_ELEMENT = 'element' +CONF_INVERT = 'invert' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Netdata' @@ -37,6 +38,7 @@ RESOURCE_SCHEMA = vol.Any({ vol.Required(CONF_DATA_GROUP): cv.string, vol.Required(CONF_ELEMENT): cv.string, vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_INVERT, default=False): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -69,6 +71,7 @@ async def async_setup_platform( icon = data[CONF_ICON] sensor = data[CONF_DATA_GROUP] element = data[CONF_ELEMENT] + invert = data[CONF_INVERT] sensor_name = entry try: resource_data = netdata.api.metrics[sensor] @@ -79,7 +82,7 @@ async def async_setup_platform( continue dev.append(NetdataSensor( - netdata, name, sensor, sensor_name, element, icon, unit)) + netdata, name, sensor, sensor_name, element, icon, unit, invert)) async_add_entities(dev, True) @@ -88,7 +91,8 @@ class NetdataSensor(Entity): """Implementation of a Netdata sensor.""" def __init__( - self, netdata, name, sensor, sensor_name, element, icon, unit): + self, netdata, name, sensor, sensor_name, element, icon, unit, + invert): """Initialize the Netdata sensor.""" self.netdata = netdata self._state = None @@ -99,6 +103,7 @@ class NetdataSensor(Entity): self._name = name self._icon = icon self._unit_of_measurement = unit + self._invert = invert @property def name(self): @@ -130,7 +135,8 @@ class NetdataSensor(Entity): await self.netdata.async_update() resource_data = self.netdata.api.metrics.get(self._sensor) self._state = round( - resource_data['dimensions'][self._element]['value'], 2) + resource_data['dimensions'][self._element]['value'], 2) \ + * (-1 if self._invert else 1) class NetdataData: diff --git a/homeassistant/components/sensor/neurio_energy.py b/homeassistant/components/sensor/neurio_energy.py index addb7925bc2..673cd8da724 100644 --- a/homeassistant/components/sensor/neurio_energy.py +++ b/homeassistant/components/sensor/neurio_energy.py @@ -11,7 +11,8 @@ import requests.exceptions import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_API_KEY) +from homeassistant.const import (CONF_API_KEY, POWER_WATT, + ENERGY_KILO_WATT_HOUR) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -148,9 +149,9 @@ class NeurioEnergy(Entity): self._state = None if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = 'W' + self._unit_of_measurement = POWER_WATT elif sensor_type == DAILY_TYPE: - self._unit_of_measurement = 'kWh' + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): diff --git a/homeassistant/components/sensor/nmbs.py b/homeassistant/components/sensor/nmbs.py index e677a072ef3..84e187fa5a4 100644 --- a/homeassistant/components/sensor/nmbs.py +++ b/homeassistant/components/sensor/nmbs.py @@ -194,7 +194,6 @@ class NMBSSensor(Entity): 'departure': "In {} minutes".format(departure), 'destination': self._station_to, 'direction': self._attrs['departure']['direction']['name'], - 'occupancy': self._attrs['departure']['occupancy']['name'], "platform_arriving": self._attrs['arrival']['platform'], "platform_departing": self._attrs['departure']['platform'], "vehicle_id": self._attrs['departure']['vehicle'], diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index 79ad176e42e..1464c0d91c1 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -13,7 +13,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN) + TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN, + POWER_WATT) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -63,8 +64,8 @@ SENSOR_TYPES = { 'ups.efficiency': ['Efficiency', '%', 'mdi:gauge'], 'ups.power': ['Current Apparent Power', 'VA', 'mdi:flash'], 'ups.power.nominal': ['Nominal Power', 'VA', 'mdi:flash'], - 'ups.realpower': ['Current Real Power', 'W', 'mdi:flash'], - 'ups.realpower.nominal': ['Nominal Real Power', 'W', 'mdi:flash'], + 'ups.realpower': ['Current Real Power', POWER_WATT, 'mdi:flash'], + 'ups.realpower.nominal': ['Nominal Real Power', POWER_WATT, 'mdi:flash'], 'ups.beeper.status': ['Beeper Status', '', 'mdi:information-outline'], 'ups.type': ['UPS Type', '', 'mdi:information-outline'], 'ups.watchdog.status': ['Watchdog Status', '', 'mdi:information-outline'], diff --git a/homeassistant/components/sensor/openevse.py b/homeassistant/components/sensor/openevse.py index eabf1739c4f..cf41f87718d 100644 --- a/homeassistant/components/sensor/openevse.py +++ b/homeassistant/components/sensor/openevse.py @@ -11,8 +11,9 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS, CONF_HOST -from homeassistant.const import CONF_MONITORED_VARIABLES +from homeassistant.const import ( + TEMP_CELSIUS, CONF_HOST, ENERGY_KILO_WATT_HOUR, + CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['openevsewifi==0.4'] @@ -25,8 +26,8 @@ SENSOR_TYPES = { 'ambient_temp': ['Ambient Temperature', TEMP_CELSIUS], 'ir_temp': ['IR Temperature', TEMP_CELSIUS], 'rtc_temp': ['RTC Temperature', TEMP_CELSIUS], - 'usage_session': ['Usage this Session', 'kWh'], - 'usage_total': ['Total Usage', 'kWh'] + 'usage_session': ['Usage this Session', ENERGY_KILO_WATT_HOUR], + 'usage_total': ['Total Usage', ENERGY_KILO_WATT_HOUR] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index d553dd8730f..08fe45a22a6 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.16.1', 'pypollencom==2.2.2'] +REQUIREMENTS = ['numpy==1.16.2', 'pypollencom==2.2.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index c77b934fbad..6fb4884989b 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.1.1'] +REQUIREMENTS = ['py17track==2.2.2'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/homeassistant/components/sensor/solaredge.py b/homeassistant/components/sensor/solaredge.py index fa49cdb3bfe..a0d76c564c1 100644 --- a/homeassistant/components/sensor/solaredge.py +++ b/homeassistant/components/sensor/solaredge.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME) + CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, POWER_WATT, + ENERGY_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -28,16 +29,16 @@ SCAN_INTERVAL = timedelta(minutes=10) # Supported sensor types: # Key: ['json_key', 'name', unit, icon] SENSOR_TYPES = { - 'lifetime_energy': ['lifeTimeData', "Lifetime energy", 'Wh', - 'mdi:solar-power'], - 'energy_this_year': ['lastYearData', "Energy this year", 'Wh', - 'mdi:solar-power'], - 'energy_this_month': ['lastMonthData', "Energy this month", 'Wh', - 'mdi:solar-power'], - 'energy_today': ['lastDayData', "Energy today", 'Wh', - 'mdi:solar-power'], - 'current_power': ['currentPower', "Current Power", 'W', - 'mdi:solar-power'] + 'lifetime_energy': ['lifeTimeData', "Lifetime energy", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_year': ['lastYearData', "Energy this year", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_month': ['lastMonthData', "Energy this month", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_today': ['lastDayData', "Energy today", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'current_power': ['currentPower', "Current Power", + POWER_WATT, 'mdi:solar-power'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/srp_energy.py b/homeassistant/components/sensor/srp_energy.py index 8e1de24a2c5..a8466bd8721 100644 --- a/homeassistant/components/sensor/srp_energy.py +++ b/homeassistant/components/sensor/srp_energy.py @@ -12,7 +12,7 @@ from requests.exceptions import ( import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, + CONF_NAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR, CONF_USERNAME, CONF_ID) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -27,7 +27,7 @@ ATTRIBUTION = "Powered by SRP Energy" DEFAULT_NAME = 'SRP Energy' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) -ENERGY_KWH = 'kWh' +ENERGY_KWH = ENERGY_KILO_WATT_HOUR ATTR_READING_COST = "reading_cost" ATTR_READING_TIME = 'datetime' diff --git a/homeassistant/components/sensor/starlingbank.py b/homeassistant/components/sensor/starlingbank.py index 9cb57670740..e325e5e1a57 100644 --- a/homeassistant/components/sensor/starlingbank.py +++ b/homeassistant/components/sensor/starlingbank.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['starlingbank==3.0'] +REQUIREMENTS = ['starlingbank==3.1'] _LOGGER = logging.getLogger(__name__) @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sterling Bank sensor platform.""" - from starlingbank import StarlingAccount # pylint: disable=syntax-error + from starlingbank import StarlingAccount sensors = [] for account in config[CONF_ACCOUNTS]: diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index 2b443738230..0d5a253483f 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -230,6 +230,9 @@ class SynoNasStorageSensor(SynoNasSensor): attr = getattr( self._api.storage, self.var_id)(self.monitor_device) + if attr is None: + return None + if self._api.temp_unit == TEMP_CELSIUS: return attr diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 70fb1f91051..8eccdc7b3b7 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -1,4 +1,5 @@ """Support for monitoring the local system.""" +from datetime import datetime import logging import os import socket @@ -34,6 +35,10 @@ SENSOR_TYPES = { 'network_out': ['Network out', 'MiB', 'mdi:server-network', None], 'packets_in': ['Packets in', ' ', 'mdi:server-network', None], 'packets_out': ['Packets out', ' ', 'mdi:server-network', None], + 'throughput_network_in': ['Network throughput in', 'MB/s', + 'mdi:server-network', None], + 'throughput_network_out': ['Network throughput out', 'MB/s', + 'mdi:server-network', None], 'process': ['Process', ' ', 'mdi:memory', None], 'processor_use': ['Processor use', '%', 'mdi:memory', None], 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk', None], @@ -54,6 +59,8 @@ IO_COUNTER = { 'network_in': 1, 'packets_out': 2, 'packets_in': 3, + 'throughput_network_out': 0, + 'throughput_network_in': 1, } IF_ADDRS_FAMILY = { @@ -84,6 +91,9 @@ class SystemMonitorSensor(Entity): self.type = sensor_type self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + if sensor_type in ['throughput_network_out', 'throughput_network_in']: + self._last_value = None + self._last_update_time = None @property def name(self): @@ -162,6 +172,22 @@ class SystemMonitorSensor(Entity): self._state = counters[self.argument][IO_COUNTER[self.type]] else: self._state = None + elif self.type == 'throughput_network_out' or\ + self.type == 'throughput_network_in': + counters = psutil.net_io_counters(pernic=True) + if self.argument in counters: + counter = counters[self.argument][IO_COUNTER[self.type]] + now = datetime.now() + if self._last_value and self._last_value < counter: + self._state = round( + (counter - self._last_value) / 1000**2 / + (now - self._last_update_time).seconds, 3) + else: + self._state = None + self._last_update_time = now + self._last_value = counter + else: + self._state = None elif self.type == 'ipv4_address' or self.type == 'ipv6_address': addresses = psutil.net_if_addrs() if self.argument in addresses: diff --git a/homeassistant/components/sensor/ted5000.py b/homeassistant/components/sensor/ted5000.py index 82a1ec8bb68..23a20b3e830 100644 --- a/homeassistant/components/sensor/ted5000.py +++ b/homeassistant/components/sensor/ted5000.py @@ -11,7 +11,8 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PORT) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, POWER_WATT) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -46,7 +47,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for mtu in gateway.data: - dev.append(Ted5000Sensor(gateway, name, mtu, 'W')) + dev.append(Ted5000Sensor(gateway, name, mtu, POWER_WATT)) dev.append(Ted5000Sensor(gateway, name, mtu, 'V')) add_entities(dev) @@ -58,7 +59,7 @@ class Ted5000Sensor(Entity): def __init__(self, gateway, name, mtu, unit): """Initialize the sensor.""" - units = {'W': 'power', 'V': 'voltage'} + units = {POWER_WATT: 'power', 'V': 'voltage'} self._gateway = gateway self._name = '{} mtu{} {}'.format(name, mtu, units[unit]) self._mtu = mtu @@ -114,4 +115,4 @@ class Ted5000Gateway: voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu] ["VoltageNow"]) - self.data[mtu] = {'W': power, 'V': voltage / 10} + self.data[mtu] = {POWER_WATT: power, 'V': voltage / 10} diff --git a/homeassistant/components/sensor/volkszaehler.py b/homeassistant/components/sensor/volkszaehler.py index 47aa580e3d4..e67d9d6424a 100644 --- a/homeassistant/components/sensor/volkszaehler.py +++ b/homeassistant/components/sensor/volkszaehler.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS, POWER_WATT, + ENERGY_WATT_HOUR) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -31,10 +32,10 @@ DEFAULT_PORT = 80 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'average': ['Average', 'W', 'mdi:power-off'], - 'consumption': ['Consumption', 'Wh', 'mdi:power-plug'], - 'max': ['Max', 'W', 'mdi:arrow-up'], - 'min': ['Min', 'W', 'mdi:arrow-down'], + 'average': ['Average', POWER_WATT, 'mdi:power-off'], + 'consumption': ['Consumption', ENERGY_WATT_HOUR, 'mdi:power-plug'], + 'max': ['Max', POWER_WATT, 'mdi:arrow-up'], + 'min': ['Min', POWER_WATT, 'mdi:arrow-down'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index 83b4f3ad934..96a4c747293 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -218,7 +218,6 @@ class WazeTravelTime(Entity): route = sorted(routes, key=(lambda key: routes[key][0]))[0] duration, distance = routes[route] - route = bytes(route, 'ISO-8859-1').decode('UTF-8') self._state = { 'duration': duration, 'distance': distance, diff --git a/homeassistant/components/sensor/whois.py b/homeassistant/components/sensor/whois.py index b589caddc79..3685652387a 100644 --- a/homeassistant/components/sensor/whois.py +++ b/homeassistant/components/sensor/whois.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pythonwhois==2.4.3'] +REQUIREMENTS = ['python-whois==0.7.1'] _LOGGER = logging.getLogger(__name__) @@ -37,21 +37,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the WHOIS sensor.""" - from pythonwhois import get_whois - from pythonwhois.shared import WhoisException + import whois domain = config.get(CONF_DOMAIN) name = config.get(CONF_NAME) try: - if 'expiration_date' in get_whois(domain, normalized=True): + if 'expiration_date' in whois.whois(domain): add_entities([WhoisSensor(name, domain)], True) else: _LOGGER.error( "WHOIS lookup for %s didn't contain expiration_date", domain) return - except WhoisException as ex: + except whois.BaseException as ex: _LOGGER.error( "Exception %s occurred during WHOIS lookup for %s", ex, domain) return @@ -62,9 +61,9 @@ class WhoisSensor(Entity): def __init__(self, name, domain): """Initialize the sensor.""" - from pythonwhois import get_whois + import whois - self.whois = get_whois + self.whois = whois.whois self._name = name self._domain = domain @@ -104,11 +103,11 @@ class WhoisSensor(Entity): def update(self): """Get the current WHOIS data for the domain.""" - from pythonwhois.shared import WhoisException + import whois try: - response = self.whois(self._domain, normalized=True) - except WhoisException as ex: + response = self.whois(self._domain) + except whois.BaseException as ex: _LOGGER.error("Exception %s occurred during WHOIS lookup", ex) self._empty_state_and_attributes() return @@ -128,17 +127,21 @@ class WhoisSensor(Entity): attrs = {} - expiration_date = response['expiration_date'][0] + expiration_date = response['expiration_date'] attrs[ATTR_EXPIRES] = expiration_date.isoformat() if 'nameservers' in response: attrs[ATTR_NAME_SERVERS] = ' '.join(response['nameservers']) if 'updated_date' in response: - attrs[ATTR_UPDATED] = response['updated_date'][0].isoformat() + update_date = response['updated_date'] + if isinstance(update_date, list): + attrs[ATTR_UPDATED] = update_date[0].isoformat() + else: + attrs[ATTR_UPDATED] = update_date.isoformat() if 'registrar' in response: - attrs[ATTR_REGISTRAR] = response['registrar'][0] + attrs[ATTR_REGISTRAR] = response['registrar'] time_delta = (expiration_date - expiration_date.now()) diff --git a/homeassistant/components/simplisafe/.translations/th.json b/homeassistant/components/simplisafe/.translations/th.json new file mode 100644 index 00000000000..84fcb89add1 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e2d\u0e35\u0e40\u0e21\u0e25" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 67213ab15bf..09584851c6a 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta from homeassistant.components.smappee import DATA_SMAPPEE from homeassistant.helpers.entity import Entity +from homeassistant.const import POWER_WATT, ENERGY_KILO_WATT_HOUR DEPENDENCIES = ['smappee'] @@ -12,9 +13,10 @@ _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = 'Smappee' SENSOR_TYPES = { 'solar': - ['Solar', 'mdi:white-balance-sunny', 'local', 'W', 'solar'], + ['Solar', 'mdi:white-balance-sunny', 'local', POWER_WATT, 'solar'], 'active_power': - ['Active Power', 'mdi:power-plug', 'local', 'W', 'active_power'], + ['Active Power', 'mdi:power-plug', 'local', POWER_WATT, + 'active_power'], 'current': ['Current', 'mdi:gauge', 'local', 'A', 'current'], 'voltage': @@ -22,11 +24,14 @@ SENSOR_TYPES = { 'active_cosfi': ['Power Factor', 'mdi:gauge', 'local', '%', 'active_cosfi'], 'alwayson_today': - ['Always On Today', 'mdi:gauge', 'remote', 'kWh', 'alwaysOn'], + ['Always On Today', 'mdi:gauge', 'remote', ENERGY_KILO_WATT_HOUR, + 'alwaysOn'], 'solar_today': - ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kWh', 'solar'], + ['Solar Today', 'mdi:white-balance-sunny', 'remote', + ENERGY_KILO_WATT_HOUR, 'solar'], 'power_today': - ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'], + ['Power Today', 'mdi:power-plug', 'remote', ENERGY_KILO_WATT_HOUR, + 'consumption'], 'water_sensor_1': ['Water Sensor 1', 'mdi:water', 'water', 'm3', 'value1'], 'water_sensor_2': diff --git a/homeassistant/components/smartthings/.translations/nl.json b/homeassistant/components/smartthings/.translations/nl.json index 93150b2ae7d..2b5b646c458 100644 --- a/homeassistant/components/smartthings/.translations/nl.json +++ b/homeassistant/components/smartthings/.translations/nl.json @@ -7,7 +7,8 @@ "token_already_setup": "Het token is al ingesteld.", "token_forbidden": "Het token heeft niet de vereiste OAuth-scopes.", "token_invalid_format": "Het token moet de UID/GUID-indeling hebben", - "token_unauthorized": "Het token is ongeldig of niet langer geautoriseerd." + "token_unauthorized": "Het token is ongeldig of niet langer geautoriseerd.", + "webhook_error": "SmartThings kon het in 'base_url` geconfigureerde endpoint niet goedkeuren. Lees de componentvereisten door." }, "step": { "user": { diff --git a/homeassistant/components/smartthings/.translations/th.json b/homeassistant/components/smartthings/.translations/th.json new file mode 100644 index 00000000000..c871679860e --- /dev/null +++ b/homeassistant/components/smartthings/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "wait_install": { + "description": "\u0e42\u0e1b\u0e23\u0e14\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07 Home Assistant SmartApp \u0e43\u0e19\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e19\u0e49\u0e2d\u0e22\u0e2b\u0e19\u0e36\u0e48\u0e07\u0e41\u0e2b\u0e48\u0e07\u0e41\u0e25\u0e49\u0e27\u0e04\u0e25\u0e34\u0e01\u0e2a\u0e48\u0e07", + "title": "\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07 SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 88035e3cc79..e5226076f46 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -25,9 +25,10 @@ from .const import ( TOKEN_REFRESH_INTERVAL) from .smartapp import ( setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions, - validate_installed_app) + unload_smartapp_endpoint, validate_installed_app, + validate_webhook_requirements) -REQUIREMENTS = ['pysmartapp==0.3.1', 'pysmartthings==0.6.7'] +REQUIREMENTS = ['pysmartapp==0.3.2', 'pysmartthings==0.6.7'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -46,23 +47,7 @@ async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): integration setup again so we can properly retrieve the needed data elements. Force this by removing the entry and triggering a new flow. """ - from pysmartthings import SmartThings - - # Remove the installed_app, which if already removed raises a 403 error. - api = SmartThings(async_get_clientsession(hass), - entry.data[CONF_ACCESS_TOKEN]) - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == 403: - _LOGGER.exception("Installed app %s has already been removed", - installed_app_id) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Delete the entry + # Remove the entry which will invoke the callback to delete the app. hass.async_create_task( hass.config_entries.async_remove(entry.entry_id)) # only create new flow if there isn't a pending one for SmartThings. @@ -80,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" from pysmartthings import SmartThings - if not hass.config.api.base_url.lower().startswith('https://'): + if not validate_webhook_requirements(hass): _LOGGER.warning("The 'base_url' of the 'http' component must be " "configured and start with 'https://'") return False @@ -194,6 +179,51 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): return all(await asyncio.gather(*tasks)) +async def async_remove_entry( + hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Perform clean-up when entry is being removed.""" + from pysmartthings import SmartThings + + api = SmartThings(async_get_clientsession(hass), + entry.data[CONF_ACCESS_TOKEN]) + + # Remove the installed_app, which if already removed raises a 403 error. + installed_app_id = entry.data[CONF_INSTALLED_APP_ID] + try: + await api.delete_installed_app(installed_app_id) + except ClientResponseError as ex: + if ex.status == 403: + _LOGGER.debug("Installed app %s has already been removed", + installed_app_id, exc_info=True) + else: + raise + _LOGGER.debug("Removed installed app %s", installed_app_id) + + # Remove the app if not referenced by other entries, which if already + # removed raises a 403 error. + all_entries = hass.config_entries.async_entries(DOMAIN) + app_id = entry.data[CONF_APP_ID] + app_count = sum(1 for entry in all_entries + if entry.data[CONF_APP_ID] == app_id) + if app_count > 1: + _LOGGER.debug("App %s was not removed because it is in use by other" + "config entries", app_id) + return + # Remove the app + try: + await api.delete_app(app_id) + except ClientResponseError as ex: + if ex.status == 403: + _LOGGER.debug("App %s has already been removed", + app_id, exc_info=True) + else: + raise + _LOGGER.debug("Removed app %s", app_id) + + if len(all_entries) == 1: + await unload_smartapp_endpoint(hass) + + class DeviceBroker: """Manages an individual SmartThings config entry.""" diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f660e905274..bcf2dc02cb0 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -7,9 +7,10 @@ from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateDevice) from homeassistant.components.climate.const import ( ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.const import ( ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -35,6 +36,25 @@ STATE_TO_MODE = { STATE_HEAT: 'heat', STATE_OFF: 'off' } + +AC_MODE_TO_STATE = { + 'auto': STATE_AUTO, + 'cool': STATE_COOL, + 'dry': STATE_DRY, + 'heat': STATE_HEAT, + 'fanOnly': STATE_FAN_ONLY +} +STATE_TO_AC_MODE = {v: k for k, v in AC_MODE_TO_STATE.items()} + +SPEED_TO_FAN_MODE = { + 0: 'auto', + 1: 'low', + 2: 'medium', + 3: 'high', + 4: 'turbo' +} +FAN_MODE_TO_SPEED = {v: k for k, v in SPEED_TO_FAN_MODE.items()} + UNIT_MAP = { 'C': TEMP_CELSIUS, 'F': TEMP_FAHRENHEIT @@ -51,10 +71,26 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Add climate entities for a config entry.""" + from pysmartthings import Capability + + ac_capabilities = [ + Capability.air_conditioner_mode, + Capability.fan_speed, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint] + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities( - [SmartThingsThermostat(device) for device in broker.devices.values() - if broker.any_assigned(device.device_id, CLIMATE_DOMAIN)], True) + entities = [] + for device in broker.devices.values(): + if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): + continue + if all(capability in device.capabilities + for capability in ac_capabilities): + entities.append(SmartThingsAirConditioner(device)) + else: + entities.append(SmartThingsThermostat(device)) + async_add_entities(entities, True) def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: @@ -62,28 +98,41 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: from pysmartthings import Capability supported = [ - Capability.thermostat, + Capability.air_conditioner_mode, + Capability.demand_response_load_control, + Capability.fan_speed, + Capability.power_consumption_report, + Capability.relative_humidity_measurement, + Capability.switch, Capability.temperature_measurement, + Capability.thermostat, Capability.thermostat_cooling_setpoint, + Capability.thermostat_fan_mode, Capability.thermostat_heating_setpoint, Capability.thermostat_mode, - Capability.relative_humidity_measurement, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode - ] + Capability.thermostat_operating_state] # Can have this legacy/deprecated capability if Capability.thermostat in capabilities: return supported - # Or must have all of these - climate_capabilities = [ + # Or must have all of these thermostat capabilities + thermostat_capabilities = [ Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, Capability.thermostat_heating_setpoint, Capability.thermostat_mode] if all(capability in capabilities - for capability in climate_capabilities): + for capability in thermostat_capabilities): + return supported + # Or must have all of these A/C capabilities + ac_capabilities = [ + Capability.air_conditioner_mode, + Capability.fan_speed, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint] + if all(capability in capabilities + for capability in ac_capabilities): return supported - return None @@ -254,5 +303,128 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateDevice): @property def temperature_unit(self): """Return the unit of measurement.""" + from pysmartthings import Attribute return UNIT_MAP.get( - self._device.status.attributes['temperature'].unit) + self._device.status.attributes[Attribute.temperature].unit) + + +class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): + """Define a SmartThings Air Conditioner.""" + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + await self._device.set_fan_speed( + FAN_MODE_TO_SPEED[fan_mode], set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + await self._device.set_air_conditioner_mode( + STATE_TO_AC_MODE[operation_mode], set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + tasks = [] + # operation mode + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + if operation_mode: + tasks.append(self.async_set_operation_mode(operation_mode)) + # temperature + tasks.append(self._device.set_cooling_setpoint( + kwargs[ATTR_TEMPERATURE], set_status=True)) + await asyncio.gather(*tasks) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_on(self): + """Turn device on.""" + await self._device.switch_on(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_off(self): + """Turn device off.""" + await self._device.switch_off(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return SPEED_TO_FAN_MODE.get(self._device.status.fan_speed) + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._device.status.temperature + + @property + def device_state_attributes(self): + """ + Return device specific state attributes. + + Include attributes from the Demand Response Load Control (drlc) + and Power Consumption capabilities. + """ + attributes = [ + 'drlc_status_duration', + 'drlc_status_level', + 'drlc_status_start', + 'drlc_status_override', + 'power_consumption_start', + 'power_consumption_power', + 'power_consumption_energy', + 'power_consumption_end' + ] + state_attributes = {} + for attribute in attributes: + value = getattr(self._device.status, attribute) + if value is not None: + state_attributes[attribute] = value + return state_attributes + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return list(FAN_MODE_TO_SPEED) + + @property + def is_on(self): + """Return true if on.""" + return self._device.status.switch + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return list(STATE_TO_AC_MODE) + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE \ + | SUPPORT_FAN_MODE | SUPPORT_ON_OFF + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._device.status.cooling_setpoint + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + from pysmartthings import Attribute + return UNIT_MAP.get( + self._device.status.attributes[Attribute.temperature].unit) diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index c290f0f8e55..da9b7c8854e 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -13,7 +13,8 @@ from .const import ( CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, DOMAIN, VAL_UID_MATCHER) from .smartapp import ( - create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app) + create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app, + validate_webhook_requirements) _LOGGER = logging.getLogger(__name__) @@ -56,10 +57,6 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): from pysmartthings import APIResponseError, AppOAuth, SmartThings errors = {} - if not self.hass.config.api.base_url.lower().startswith('https://'): - errors['base'] = "base_url_not_https" - return self._show_step_user(errors) - if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_user(errors) @@ -81,6 +78,10 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): # Setup end-point await setup_smartapp_endpoint(self.hass) + if not validate_webhook_requirements(self.hass): + errors['base'] = "base_url_not_https" + return self._show_step_user(errors) + try: app = await find_app(self.hass, self.api) if app: diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 105c9760e12..aa7cba8e74c 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -8,6 +8,7 @@ APP_OAUTH_SCOPES = [ ] APP_NAME_PREFIX = 'homeassistant.' CONF_APP_ID = 'app_id' +CONF_CLOUDHOOK_URL = 'cloudhook_url' CONF_INSTALLED_APP_ID = 'installed_app_id' CONF_INSTALLED_APPS = 'installed_apps' CONF_INSTANCE_ID = 'instance_id' diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 50beefdb5b2..4f7ad1a1398 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,7 +5,7 @@ from typing import Optional, Sequence from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, MASS_KILOGRAMS, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -54,7 +54,7 @@ CAPABILITY_TO_SENSORS = { Map('fineDustLevel', "Fine Dust Level", None, None), Map('dustLevel', "Dust Level", None, None)], 'energyMeter': [ - Map('energy', "Energy Meter", 'kWh', None)], + Map('energy', "Energy Meter", ENERGY_KILO_WATT_HOUR, None)], 'equivalentCarbonDioxideMeasurement': [ Map('equivalentCarbonDioxideMeasurement', 'Equivalent Carbon Dioxide Measurement', 'ppm', None)], @@ -85,11 +85,11 @@ CAPABILITY_TO_SENSORS = { 'ovenSetpoint': [ Map('ovenSetpoint', "Oven Set Point", None, None)], 'powerMeter': [ - Map('power', "Power Meter", 'W', None)], + Map('power', "Power Meter", POWER_WATT, None)], 'powerSource': [ Map('powerSource', "Power Source", None, None)], 'refrigerationSetpoint': [ - Map('refrigerationSetpoint', "Refrigeration Setpoint", TEMP_CELSIUS, + Map('refrigerationSetpoint', "Refrigeration Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'relativeHumidityMeasurement': [ Map('humidity', "Relative Humidity Measurement", '%', @@ -107,15 +107,15 @@ CAPABILITY_TO_SENSORS = { 'smokeDetector': [ Map('smoke', "Smoke Detector", None, None)], 'temperatureMeasurement': [ - Map('temperature', "Temperature Measurement", TEMP_CELSIUS, + Map('temperature', "Temperature Measurement", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatCoolingSetpoint': [ - Map('coolingSetpoint', "Thermostat Cooling Setpoint", TEMP_CELSIUS, + Map('coolingSetpoint', "Thermostat Cooling Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatFanMode': [ Map('thermostatFanMode', "Thermostat Fan Mode", None, None)], 'thermostatHeatingSetpoint': [ - Map('heatingSetpoint', "Thermostat Heating Setpoint", TEMP_CELSIUS, + Map('heatingSetpoint', "Thermostat Heating Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatMode': [ Map('thermostatMode', "Thermostat Mode", None, None)], @@ -123,8 +123,10 @@ CAPABILITY_TO_SENSORS = { Map('thermostatOperatingState', "Thermostat Operating State", None, None)], 'thermostatSetpoint': [ - Map('thermostatSetpoint', "Thermostat Setpoint", TEMP_CELSIUS, + Map('thermostatSetpoint', "Thermostat Setpoint", None, DEVICE_CLASS_TEMPERATURE)], + 'threeAxis': [ + Map('threeAxis', "Three Axis", None, None)], 'tvChannel': [ Map('tvChannel', "Tv Channel", None, None)], 'tvocMeasurement': [ @@ -147,6 +149,8 @@ UNITS = { 'F': TEMP_FAHRENHEIT } +THREE_AXIS_NAMES = ['X Coordinate', 'Y Coordinate', 'Z Coordinate'] + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -156,16 +160,22 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Add binary sensors for a config entry.""" + from pysmartthings import Capability broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] sensors = [] for device in broker.devices.values(): for capability in broker.get_assigned(device.device_id, 'sensor'): - maps = CAPABILITY_TO_SENSORS[capability] - sensors.extend([ - SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, - m.device_class) - for m in maps]) + if capability == Capability.three_axis: + sensors.extend( + [SmartThingsThreeAxisSensor(device, index) + for index in range(len(THREE_AXIS_NAMES))]) + else: + maps = CAPABILITY_TO_SENSORS[capability] + sensors.extend([ + SmartThingsSensor( + device, m.attribute, m.name, m.default_unit, + m.device_class) + for m in maps]) async_add_entities(sensors) @@ -176,7 +186,7 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: class SmartThingsSensor(SmartThingsEntity): - """Define a SmartThings Binary Sensor.""" + """Define a SmartThings Sensor.""" def __init__(self, device, attribute: str, name: str, default_unit: str, device_class: str): @@ -212,3 +222,34 @@ class SmartThingsSensor(SmartThingsEntity): """Return the unit this state is expressed in.""" unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit + + +class SmartThingsThreeAxisSensor(SmartThingsEntity): + """Define a SmartThings Three Axis Sensor.""" + + def __init__(self, device, index): + """Init the class.""" + super().__init__(device) + self._index = index + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return '{} {}'.format( + self._device.label, THREE_AXIS_NAMES[self._index]) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return '{}.{}'.format( + self._device.device_id, THREE_AXIS_NAMES[self._index]) + + @property + def state(self): + """Return the state of the sensor.""" + from pysmartthings import Attribute + three_axis = self._device.status.attributes[Attribute.three_axis].value + try: + return three_axis[self._index] + except (TypeError, IndexError): + return None diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 5527fda54f4..0b64bac5956 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -8,11 +8,12 @@ callbacks when device states change. import asyncio import functools import logging +from urllib.parse import urlparse from uuid import uuid4 from aiohttp import web -from homeassistant.components import webhook +from homeassistant.components import cloud, webhook from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -21,9 +22,10 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ( APP_NAME_PREFIX, APP_OAUTH_CLIENT_NAME, APP_OAUTH_SCOPES, CONF_APP_ID, - CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, CONF_INSTANCE_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DATA_MANAGER, DOMAIN, - SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION) + CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, + CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, + DATA_MANAGER, DOMAIN, SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, + STORAGE_KEY, STORAGE_VERSION) _LOGGER = logging.getLogger(__name__) @@ -59,15 +61,39 @@ async def validate_installed_app(api, installed_app_id: str): return installed_app +def validate_webhook_requirements(hass: HomeAssistantType) -> bool: + """Ensure HASS is setup properly to receive webhooks.""" + if cloud.async_active_subscription(hass): + return True + return get_webhook_url(hass).lower().startswith('https://') + + +def get_webhook_url(hass: HomeAssistantType) -> str: + """ + Get the URL of the webhook. + + Return the cloudhook if available, otherwise local webhook. + """ + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloud.async_active_subscription(hass) and cloudhook_url is not None: + return cloudhook_url + return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + + def _get_app_template(hass: HomeAssistantType): from pysmartthings import APP_TYPE_WEBHOOK, CLASSIFICATION_AUTOMATION + endpoint = "at " + hass.config.api.base_url + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloudhook_url is not None: + endpoint = "via Nabu Casa" + description = "{} {}".format(hass.config.location_name, endpoint) + return { 'app_name': APP_NAME_PREFIX + str(uuid4()), 'display_name': 'Home Assistant', - 'description': "Home Assistant at " + hass.config.api.base_url, - 'webhook_target_url': webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + 'description': description, + 'webhook_target_url': get_webhook_url(hass), 'app_type': APP_TYPE_WEBHOOK, 'single_instance': True, 'classifications': [CLASSIFICATION_AUTOMATION] @@ -162,33 +188,80 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType): # Create config config = { CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: webhook.generate_secret() + CONF_WEBHOOK_ID: webhook.generate_secret(), + CONF_CLOUDHOOK_URL: None } await store.async_save(config) + # Register webhook + webhook.async_register(hass, DOMAIN, 'SmartApp', + config[CONF_WEBHOOK_ID], smartapp_webhook) + + # Create webhook if eligible + cloudhook_url = config.get(CONF_CLOUDHOOK_URL) + if cloudhook_url is None \ + and cloud.async_active_subscription(hass) \ + and not hass.config_entries.async_entries(DOMAIN): + cloudhook_url = await cloud.async_create_cloudhook( + hass, config[CONF_WEBHOOK_ID]) + config[CONF_CLOUDHOOK_URL] = cloudhook_url + await store.async_save(config) + _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) + # SmartAppManager uses a dispatcher to invoke callbacks when push events # occur. Use hass' implementation instead of the built-in one. dispatcher = Dispatcher( signal_prefix=SIGNAL_SMARTAPP_PREFIX, connect=functools.partial(async_dispatcher_connect, hass), send=functools.partial(async_dispatcher_send, hass)) - manager = SmartAppManager( - webhook.async_generate_path(config[CONF_WEBHOOK_ID]), - dispatcher=dispatcher) + # Path is used in digital signature validation + path = urlparse(cloudhook_url).path if cloudhook_url else \ + webhook.async_generate_path(config[CONF_WEBHOOK_ID]) + manager = SmartAppManager(path, dispatcher=dispatcher) manager.connect_install(functools.partial(smartapp_install, hass)) manager.connect_update(functools.partial(smartapp_update, hass)) manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - webhook.async_register(hass, DOMAIN, 'SmartApp', - config[CONF_WEBHOOK_ID], smartapp_webhook) - hass.data[DOMAIN] = { DATA_MANAGER: manager, CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], DATA_BROKERS: {}, CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], + # Will not be present if not enabled + CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), CONF_INSTALLED_APPS: [] } + _LOGGER.debug("Setup endpoint for %s", + cloudhook_url if cloudhook_url else + webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID])) + + +async def unload_smartapp_endpoint(hass: HomeAssistantType): + """Tear down the component configuration.""" + if DOMAIN not in hass.data: + return + # Remove the cloudhook if it was created + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloudhook_url and cloud.async_is_logged_in(hass): + await cloud.async_delete_cloudhook( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + # Remove cloudhook from storage + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + await store.async_save({ + CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], + CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], + CONF_CLOUDHOOK_URL: None + }) + _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) + # Remove the webhook + webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + # Disconnect all brokers + for broker in hass.data[DOMAIN][DATA_BROKERS].values(): + broker.disconnect() + # Remove all handlers from manager + hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() + # Remove the component data + hass.data.pop(DOMAIN) async def smartapp_sync_subscriptions( @@ -285,6 +358,9 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): # Store the data where the flow can find it hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data) + _LOGGER.debug("Installed SmartApp '%s' under parent app '%s'", + req.installed_app_id, app.app_id) + async def smartapp_update(hass: HomeAssistantType, req, resp, app): """ @@ -301,7 +377,7 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): entry.data[CONF_REFRESH_TOKEN] = req.refresh_token hass.config_entries.async_update_entry(entry) - _LOGGER.debug("SmartApp '%s' under parent app '%s' was updated", + _LOGGER.debug("Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id) @@ -316,12 +392,13 @@ async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app): req.installed_app_id), None) if entry: - _LOGGER.debug("SmartApp '%s' under parent app '%s' was removed", - req.installed_app_id, app.app_id) # Add as job not needed because the current coroutine was invoked # from the dispatcher and is not being awaited. await hass.config_entries.async_remove(entry.entry_id) + _LOGGER.debug("Uninstalled SmartApp '%s' under parent app '%s'", + req.installed_app_id, app.app_id) + async def smartapp_webhook(hass: HomeAssistantType, webhook_id: str, request): """ diff --git a/homeassistant/components/smhi/.translations/th.json b/homeassistant/components/smhi/.translations/th.json new file mode 100644 index 00000000000..0c08363fca6 --- /dev/null +++ b/homeassistant/components/smhi/.translations/th.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "\u0e25\u0e30\u0e15\u0e34\u0e08\u0e39\u0e14", + "longitude": "\u0e25\u0e2d\u0e07\u0e08\u0e34\u0e08\u0e39\u0e14", + "name": "\u0e0a\u0e37\u0e48\u0e2d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 20cc7137ef8..0cc96d66b1a 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -95,14 +95,14 @@ async def async_setup(hass, config): if CONF_FEEDBACK in config[DOMAIN]: async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) - async def message_received(topic, payload, qos): + async def message_received(msg): """Handle new messages on MQTT.""" - _LOGGER.debug("New intent: %s", payload) + _LOGGER.debug("New intent: %s", msg.payload) try: - request = json.loads(payload) + request = json.loads(msg.payload) except TypeError: - _LOGGER.error('Received invalid JSON: %s', payload) + _LOGGER.error('Received invalid JSON: %s', msg.payload) return if (request['intent']['confidenceScore'] diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e0f881f723d..684e25ba599 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,9 +3,10 @@ import datetime import functools as ft import logging import socket -import threading +import asyncio import urllib +import async_timeout import requests import voluptuous as vol @@ -111,11 +112,11 @@ SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ class SonosData: """Storage class for platform global data.""" - def __init__(self): + def __init__(self, hass): """Initialize the data.""" self.uids = set() self.entities = [] - self.topology_lock = threading.Lock() + self.topology_condition = asyncio.Condition(loop=hass.loop) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -143,7 +144,7 @@ def _setup_platform(hass, config, add_entities, discovery_info): import pysonos if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = SonosData() + hass.data[DATA_SONOS] = SonosData(hass) advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: @@ -187,57 +188,62 @@ def _setup_platform(hass, config, add_entities, discovery_info): add_entities(SonosEntity(p) for p in players) _LOGGER.debug("Added %s Sonos speakers", len(players)) - def service_handle(service): - """Handle for services.""" + def _service_to_entities(service): + """Extract and return entities from service call.""" entity_ids = service.data.get('entity_id') entities = hass.data[DATA_SONOS].entities if entity_ids: entities = [e for e in entities if e.entity_id in entity_ids] - with hass.data[DATA_SONOS].topology_lock: - if service.service == SERVICE_SNAPSHOT: - SonosEntity.snapshot_multi( - entities, service.data[ATTR_WITH_GROUP]) - elif service.service == SERVICE_RESTORE: - SonosEntity.restore_multi( - entities, service.data[ATTR_WITH_GROUP]) - elif service.service == SERVICE_JOIN: - master = [e for e in hass.data[DATA_SONOS].entities - if e.entity_id == service.data[ATTR_MASTER]] - if master: - master[0].join(entities) - else: - for entity in entities: - if service.service == SERVICE_UNJOIN: - entity.unjoin() - elif service.service == SERVICE_SET_TIMER: - entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) - elif service.service == SERVICE_CLEAR_TIMER: - entity.clear_sleep_timer() - elif service.service == SERVICE_UPDATE_ALARM: - entity.set_alarm(**service.data) - elif service.service == SERVICE_SET_OPTION: - entity.set_option(**service.data) + return entities - entity.schedule_update_ha_state(True) + async def async_service_handle(service): + """Handle async services.""" + entities = _service_to_entities(service) + + if service.service == SERVICE_JOIN: + master = [e for e in hass.data[DATA_SONOS].entities + if e.entity_id == service.data[ATTR_MASTER]] + if master: + await SonosEntity.join_multi(hass, master[0], entities) + elif service.service == SERVICE_UNJOIN: + await SonosEntity.unjoin_multi(hass, entities) + elif service.service == SERVICE_SNAPSHOT: + await SonosEntity.snapshot_multi( + hass, entities, service.data[ATTR_WITH_GROUP]) + elif service.service == SERVICE_RESTORE: + await SonosEntity.restore_multi( + hass, entities, service.data[ATTR_WITH_GROUP]) hass.services.register( - DOMAIN, SERVICE_JOIN, service_handle, + DOMAIN, SERVICE_JOIN, async_service_handle, schema=SONOS_JOIN_SCHEMA) hass.services.register( - DOMAIN, SERVICE_UNJOIN, service_handle, + DOMAIN, SERVICE_UNJOIN, async_service_handle, schema=SONOS_SCHEMA) hass.services.register( - DOMAIN, SERVICE_SNAPSHOT, service_handle, + DOMAIN, SERVICE_SNAPSHOT, async_service_handle, schema=SONOS_STATES_SCHEMA) hass.services.register( - DOMAIN, SERVICE_RESTORE, service_handle, + DOMAIN, SERVICE_RESTORE, async_service_handle, schema=SONOS_STATES_SCHEMA) + def service_handle(service): + """Handle sync services.""" + for entity in _service_to_entities(service): + if service.service == SERVICE_SET_TIMER: + entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) + elif service.service == SERVICE_CLEAR_TIMER: + entity.clear_sleep_timer() + elif service.service == SERVICE_UPDATE_ALARM: + entity.set_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + entity.set_option(**service.data) + hass.services.register( DOMAIN, SERVICE_SET_TIMER, service_handle, schema=SONOS_SET_TIMER_SCHEMA) @@ -359,7 +365,6 @@ class SonosEntity(MediaPlayerDevice): self._favorites = None self._soco_snapshot = None self._snapshot_group = None - self._restore_pending = False self._set_basic_information() @@ -701,52 +706,67 @@ class SonosEntity(MediaPlayerDevice): self._speech_enhance = self.soco.dialog_mode def update_groups(self, event=None): - """Process a zone group topology event coming from a player.""" + """Handle callback for topology change event.""" + def _get_soco_group(): + """Ask SoCo cache for existing topology.""" + coordinator_uid = self.unique_id + slave_uids = [] + + try: + if self.soco.group and self.soco.group.coordinator: + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + except requests.exceptions.RequestException: + pass + + return [coordinator_uid] + slave_uids + + async def _async_extract_group(event): + """Extract group layout from a topology event.""" + group = event and event.zone_player_uui_ds_in_group + if group: + return group.split(',') + + return await self.hass.async_add_executor_job(_get_soco_group) + + def _async_regroup(group): + """Rebuild internal group layout.""" + sonos_group = [] + for uid in group: + entity = _get_entity_from_soco_uid(self.hass, uid) + if entity: + sonos_group.append(entity) + + self._coordinator = None + self._sonos_group = sonos_group + self.async_schedule_update_ha_state() + + for slave_uid in group[1:]: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave._sonos_group = sonos_group + slave.async_schedule_update_ha_state() + + async def _async_handle_group_event(event): + """Get async lock and handle event.""" + async with self.hass.data[DATA_SONOS].topology_condition: + group = await _async_extract_group(event) + + if self.unique_id == group[0]: + _async_regroup(group) + + self.hass.data[DATA_SONOS].topology_condition.notify_all() + if event: self._receives_events = True if not hasattr(event, 'zone_player_uui_ds_in_group'): return - with self.hass.data[DATA_SONOS].topology_lock: - group = event and event.zone_player_uui_ds_in_group - if group: - # New group information is pushed - coordinator_uid, *slave_uids = group.split(',') - else: - coordinator_uid = self.unique_id - slave_uids = [] - - # Try SoCo cache for existing topology - try: - if self.soco.group and self.soco.group.coordinator: - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [p.uid for p in self.soco.group.members - if p.uid != coordinator_uid] - except requests.exceptions.RequestException: - pass - - if self.unique_id == coordinator_uid: - if self._restore_pending: - self.restore() - - sonos_group = [] - for uid in (coordinator_uid, *slave_uids): - entity = _get_entity_from_soco_uid(self.hass, uid) - if entity: - sonos_group.append(entity) - - self._coordinator = None - self._sonos_group = sonos_group - self.schedule_update_ha_state() - - for slave_uid in slave_uids: - slave = _get_entity_from_soco_uid(self.hass, slave_uid) - if slave: - # pylint: disable=protected-access - slave._coordinator = self - slave._sonos_group = sonos_group - slave.schedule_update_ha_state() + self.hass.add_job(_async_handle_group_event(event)) def update_content(self, event=None): """Update information about available content.""" @@ -967,12 +987,26 @@ class SonosEntity(MediaPlayerDevice): """Form a group with other players.""" if self._coordinator: self.unjoin() + group = [self] + else: + group = self._sonos_group.copy() for slave in slaves: if slave.unique_id != self.unique_id: slave.soco.join(self.soco) # pylint: disable=protected-access slave._coordinator = self + if slave not in group: + group.append(slave) + + return group + + @staticmethod + async def join_multi(hass, master, entities): + """Form a group with other players.""" + async with hass.data[DATA_SONOS].topology_condition: + group = await hass.async_add_executor_job(master.join, entities) + await SonosEntity.wait_for_groups(hass, [group]) @soco_error() def unjoin(self): @@ -980,6 +1014,22 @@ class SonosEntity(MediaPlayerDevice): self.soco.unjoin() self._coordinator = None + @staticmethod + async def unjoin_multi(hass, entities): + """Unjoin several players from their group.""" + def _unjoin_all(entities): + """Sync helper.""" + # Unjoin slaves first to prevent inheritance of queues + coordinators = [e for e in entities if e.is_coordinator] + slaves = [e for e in entities if not e.is_coordinator] + + for entity in slaves + coordinators: + entity.unjoin() + + async with hass.data[DATA_SONOS].topology_condition: + await hass.async_add_executor_job(_unjoin_all, entities) + await SonosEntity.wait_for_groups(hass, [[e] for e in entities]) + @soco_error() def snapshot(self, with_group): """Snapshot the state of a player.""" @@ -992,6 +1042,25 @@ class SonosEntity(MediaPlayerDevice): else: self._snapshot_group = None + @staticmethod + async def snapshot_multi(hass, entities, with_group): + """Snapshot all the entities and optionally their groups.""" + # pylint: disable=protected-access + + def _snapshot_all(entities): + """Sync helper.""" + for entity in entities: + entity.snapshot(with_group) + + # Find all affected players + entities = set(entities) + if with_group: + for entity in list(entities): + entities.update(entity._sonos_group) + + async with hass.data[DATA_SONOS].topology_condition: + await hass.async_add_executor_job(_snapshot_all, entities) + @soco_error() def restore(self): """Restore a snapshotted state to a player.""" @@ -999,7 +1068,6 @@ class SonosEntity(MediaPlayerDevice): try: # pylint: disable=protected-access - self.soco._zgs_cache.clear() self._soco_snapshot.restore() except (TypeError, AttributeError, SoCoException) as ex: # Can happen if restoring a coordinator onto a current slave @@ -1007,54 +1075,86 @@ class SonosEntity(MediaPlayerDevice): self._soco_snapshot = None self._snapshot_group = None - self._restore_pending = False @staticmethod - def snapshot_multi(entities, with_group): - """Snapshot all the entities and optionally their groups.""" - # pylint: disable=protected-access - # Find all affected players - entities = set(entities) - if with_group: - for entity in list(entities): - entities.update(entity._sonos_group) - - for entity in entities: - entity.snapshot(with_group) - - @staticmethod - def restore_multi(entities, with_group): + async def restore_multi(hass, entities, with_group): """Restore snapshots for all the entities.""" # pylint: disable=protected-access + + def _restore_groups(entities, with_group): + """Pause all current coordinators and restore groups.""" + for entity in (e for e in entities if e.is_coordinator): + if entity.state == STATE_PLAYING: + entity.media_pause() + + groups = [] + + if with_group: + # Unjoin slaves first to prevent inheritance of queues + for entity in [e for e in entities if not e.is_coordinator]: + if entity._snapshot_group != entity._sonos_group: + entity.unjoin() + + # Bring back the original group topology + for entity in (e for e in entities if e._snapshot_group): + if entity._snapshot_group[0] == entity: + entity.join(entity._snapshot_group) + groups.append(entity._snapshot_group.copy()) + + return groups + + def _restore_players(entities): + """Restore state of all players.""" + for entity in (e for e in entities if not e.is_coordinator): + entity.restore() + + for entity in (e for e in entities if e.is_coordinator): + entity.restore() + # Find all affected players entities = set(e for e in entities if e._soco_snapshot) if with_group: for entity in [e for e in entities if e._snapshot_group]: entities.update(entity._snapshot_group) - # Pause all current coordinators - for entity in (e for e in entities if e.is_coordinator): - if entity.state == STATE_PLAYING: - entity.media_pause() + async with hass.data[DATA_SONOS].topology_condition: + groups = await hass.async_add_executor_job( + _restore_groups, entities, with_group) - # Bring back the original group topology - if with_group: - for entity in (e for e in entities if e._snapshot_group): - if entity._snapshot_group[0] == entity: - entity.join(entity._snapshot_group) + await SonosEntity.wait_for_groups(hass, groups) - # Restore slaves - for entity in (e for e in entities if not e.is_coordinator): - entity.restore() + await hass.async_add_executor_job(_restore_players, entities) - # Restore coordinators (or delay if moving from slave) - for entity in (e for e in entities if e.is_coordinator): - if entity._sonos_group[0] == entity: - # Was already coordinator - entity.restore() - else: - # Await coordinator role - entity._restore_pending = True + @staticmethod + async def wait_for_groups(hass, groups): + """Wait until all groups are present, or timeout.""" + # pylint: disable=protected-access + + def _test_groups(groups): + """Return whether all groups exist now.""" + for group in groups: + coordinator = group[0] + + # Test that coordinator is coordinating + current_group = coordinator._sonos_group + if coordinator != current_group[0]: + return False + + # Test that slaves match + if set(group[1:]) != set(current_group[1:]): + return False + + return True + + try: + with async_timeout.timeout(5): + while not _test_groups(groups): + await hass.data[DATA_SONOS].topology_condition.wait() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout waiting for target groups %s", groups) + + for entity in hass.data[DATA_SONOS].entities: + entity.soco._zgs_cache.clear() @soco_error() @soco_coordinator diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py new file mode 100644 index 00000000000..3f715af0e04 --- /dev/null +++ b/homeassistant/components/stream/__init__.py @@ -0,0 +1,159 @@ +""" +Provide functionality to stream video source. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/stream/ +""" +import logging +import threading + +import voluptuous as vol + +from homeassistant.auth.util import generate_secret +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass + +from .const import DOMAIN, ATTR_STREAMS, ATTR_ENDPOINTS +from .core import PROVIDERS +from .worker import stream_worker +from .hls import async_setup_hls + +REQUIREMENTS = ['av==6.1.2'] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({}), +}, extra=vol.ALLOW_EXTRA) + +# Set log level to error for libav +logging.getLogger('libav').setLevel(logging.ERROR) + + +@bind_hass +def request_stream(hass, stream_source, *, fmt='hls', + keepalive=False, options=None): + """Set up stream with token.""" + if DOMAIN not in hass.config.components: + raise HomeAssistantError("Stream component is not set up.") + + if options is None: + options = {} + + try: + streams = hass.data[DOMAIN][ATTR_STREAMS] + stream = streams.get(stream_source) + if not stream: + stream = Stream(hass, stream_source, + options=options, keepalive=keepalive) + streams[stream_source] = stream + + # Add provider + stream.add_provider(fmt) + + if not stream.access_token: + stream.access_token = generate_secret() + stream.start() + return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format( + stream.access_token) + except Exception: + raise HomeAssistantError('Unable to get stream') + + +async def async_setup(hass, config): + """Set up stream.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][ATTR_ENDPOINTS] = {} + hass.data[DOMAIN][ATTR_STREAMS] = {} + + # Setup HLS + hls_endpoint = async_setup_hls(hass) + hass.data[DOMAIN][ATTR_ENDPOINTS]['hls'] = hls_endpoint + + @callback + def shutdown(event): + """Stop all stream workers.""" + for stream in hass.data[DOMAIN][ATTR_STREAMS].values(): + stream.keepalive = False + stream.stop() + _LOGGER.info("Stopped stream workers.") + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + return True + + +class Stream: + """Represents a single stream.""" + + def __init__(self, hass, source, options=None, keepalive=False): + """Initialize a stream.""" + self.hass = hass + self.source = source + self.options = options + self.keepalive = keepalive + self.access_token = None + self._thread = None + self._thread_quit = None + self._outputs = {} + + if self.options is None: + self.options = {} + + @property + def outputs(self): + """Return stream outputs.""" + return self._outputs + + def add_provider(self, fmt): + """Add provider output stream.""" + provider = PROVIDERS[fmt](self) + if not self._outputs.get(provider.format): + self._outputs[provider.format] = provider + return self._outputs[provider.format] + + def remove_provider(self, provider): + """Remove provider output stream.""" + if provider.format in self._outputs: + del self._outputs[provider.format] + self.check_idle() + + if not self._outputs: + self.stop() + + def check_idle(self): + """Reset access token if all providers are idle.""" + if all([p.idle for p in self._outputs.values()]): + self.access_token = None + + def start(self): + """Start a stream.""" + if self._thread is None or not self._thread.isAlive(): + self._thread_quit = threading.Event() + self._thread = threading.Thread( + name='stream_worker', + target=stream_worker, + args=( + self.hass, self, self._thread_quit)) + self._thread.start() + _LOGGER.info("Started stream: %s", self.source) + + def stop(self): + """Remove outputs and access token.""" + self._outputs = {} + self.access_token = None + + if not self.keepalive: + self._stop() + + def _stop(self): + """Stop worker thread.""" + if self._thread is not None: + self._thread_quit.set() + self._thread.join() + self._thread = None + _LOGGER.info("Stopped stream: %s", self.source) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py new file mode 100644 index 00000000000..a87daaa9d40 --- /dev/null +++ b/homeassistant/components/stream/const.py @@ -0,0 +1,14 @@ +"""Constants for Stream component.""" +DOMAIN = 'stream' + +ATTR_ENDPOINTS = 'endpoints' +ATTR_STREAMS = 'streams' +ATTR_KEEPALIVE = 'keepalive' + +OUTPUT_FORMATS = ['hls'] + +FORMAT_CONTENT_TYPE = { + 'hls': 'application/vnd.apple.mpegurl' +} + +AUDIO_SAMPLE_RATE = 44100 diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py new file mode 100644 index 00000000000..665803d38eb --- /dev/null +++ b/homeassistant/components/stream/core.py @@ -0,0 +1,172 @@ +"""Provides core stream functionality.""" +import asyncio +from collections import deque +import io +from typing import List, Any + +import attr +from aiohttp import web + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.event import async_call_later +from homeassistant.util.decorator import Registry + +from .const import DOMAIN, ATTR_STREAMS + +PROVIDERS = Registry() + + +@attr.s +class StreamBuffer: + """Represent a segment.""" + + segment = attr.ib(type=io.BytesIO) + output = attr.ib() # type=av.OutputContainer + vstream = attr.ib() # type=av.VideoStream + astream = attr.ib(default=None) # type=av.AudioStream + + +@attr.s +class Segment: + """Represent a segment.""" + + sequence = attr.ib(type=int) + segment = attr.ib(type=io.BytesIO) + duration = attr.ib(type=float) + + +class StreamOutput: + """Represents a stream output.""" + + num_segments = 3 + + def __init__(self, stream) -> None: + """Initialize a stream output.""" + self.idle = False + self._stream = stream + self._cursor = None + self._event = asyncio.Event() + self._segments = deque(maxlen=self.num_segments) + self._unsub = None + + @property + def format(self) -> str: + """Return container format.""" + return None + + @property + def audio_codec(self) -> str: + """Return desired audio codec.""" + return None + + @property + def video_codec(self) -> str: + """Return desired video codec.""" + return None + + @property + def segments(self) -> List[int]: + """Return current sequence from segments.""" + return [s.sequence for s in self._segments] + + @property + def target_duration(self) -> int: + """Return the average duration of the segments in seconds.""" + durations = [s.duration for s in self._segments] + return round(sum(durations) // len(self._segments)) or 1 + + def get_segment(self, sequence: int = None) -> Any: + """Retrieve a specific segment, or the whole list.""" + self.idle = False + # Reset idle timeout + if self._unsub is not None: + self._unsub() + self._unsub = async_call_later(self._stream.hass, 300, self._timeout) + + if not sequence: + return self._segments + + for segment in self._segments: + if segment.sequence == sequence: + return segment + return None + + async def recv(self) -> Segment: + """Wait for and retrieve the latest segment.""" + last_segment = max(self.segments, default=0) + if self._cursor is None or self._cursor <= last_segment: + await self._event.wait() + + if not self._segments: + return None + + segment = self.get_segment()[-1] + self._cursor = segment.sequence + return segment + + @callback + def put(self, segment: Segment) -> None: + """Store output.""" + # Start idle timeout when we start recieving data + if self._unsub is None: + self._unsub = async_call_later( + self._stream.hass, 300, self._timeout) + + if segment is None: + self._event.set() + # Cleanup provider + if self._unsub is not None: + self._unsub() + self._cleanup() + return + + self._segments.append(segment) + self._event.set() + self._event.clear() + + @callback + def _timeout(self, _now=None): + """Handle stream timeout.""" + if self._stream.keepalive: + self.idle = True + self._stream.check_idle() + else: + self._cleanup() + + def _cleanup(self): + """Remove provider.""" + self._segments = [] + self._stream.remove_provider(self) + + +class StreamView(HomeAssistantView): + """ + Base StreamView. + + For implementation of a new stream format, define `url` and `name` + attributes, and implement `handle` method in a child class. + """ + + requires_auth = False + platform = None + + async def get(self, request, token, sequence=None): + """Start a GET request.""" + hass = request.app['hass'] + + stream = next(( + s for s in hass.data[DOMAIN][ATTR_STREAMS].values() + if s.access_token == token), None) + + if not stream: + raise web.HTTPNotFound() + + # Start worker if not already started + stream.start() + + return await self.handle(request, stream, sequence) + + async def handle(self, request, stream, sequence): + """Handle the stream request.""" + raise NotImplementedError() diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py new file mode 100644 index 00000000000..8f5dd6c1884 --- /dev/null +++ b/homeassistant/components/stream/hls.py @@ -0,0 +1,126 @@ +""" +Provide functionality to stream HLS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/stream/hls +""" +from aiohttp import web + +from homeassistant.core import callback +from homeassistant.util.dt import utcnow + +from .const import FORMAT_CONTENT_TYPE +from .core import StreamView, StreamOutput, PROVIDERS + + +@callback +def async_setup_hls(hass): + """Set up api endpoints.""" + hass.http.register_view(HlsPlaylistView()) + hass.http.register_view(HlsSegmentView()) + return '/api/hls/{}/playlist.m3u8' + + +class HlsPlaylistView(StreamView): + """Stream view to serve a M3U8 stream.""" + + url = r'/api/hls/{token:[a-f0-9]+}/playlist.m3u8' + name = 'api:stream:hls:playlist' + cors_allowed = True + + async def handle(self, request, stream, sequence): + """Return m3u8 playlist.""" + renderer = M3U8Renderer(stream) + track = stream.add_provider('hls') + stream.start() + # Wait for a segment to be ready + if not track.segments: + await track.recv() + headers = { + 'Content-Type': FORMAT_CONTENT_TYPE['hls'] + } + return web.Response(body=renderer.render( + track, utcnow()).encode("utf-8"), headers=headers) + + +class HlsSegmentView(StreamView): + """Stream view to serve a MPEG2TS segment.""" + + url = r'/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.ts' + name = 'api:stream:hls:segment' + cors_allowed = True + + async def handle(self, request, stream, sequence): + """Return mpegts segment.""" + track = stream.add_provider('hls') + segment = track.get_segment(int(sequence)) + if not segment: + return web.HTTPNotFound() + headers = { + 'Content-Type': 'video/mp2t' + } + return web.Response(body=segment.segment.getvalue(), headers=headers) + + +class M3U8Renderer: + """M3U8 Render Helper.""" + + def __init__(self, stream): + """Initialize renderer.""" + self.stream = stream + + @staticmethod + def render_preamble(track): + """Render preamble.""" + return [ + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:{}".format(track.target_duration), + ] + + @staticmethod + def render_playlist(track, start_time): + """Render playlist.""" + segments = track.segments + + if not segments: + return [] + + playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])] + + for sequence in segments: + segment = track.get_segment(sequence) + playlist.extend([ + "#EXTINF:{:.04},".format(float(segment.duration)), + "./segment/{}.ts".format(segment.sequence), + ]) + + return playlist + + def render(self, track, start_time): + """Render M3U8 file.""" + lines = ( + ["#EXTM3U"] + + self.render_preamble(track) + + self.render_playlist(track, start_time) + ) + return "\n".join(lines) + "\n" + + +@PROVIDERS.register('hls') +class HlsStreamOutput(StreamOutput): + """Represents HLS Output formats.""" + + @property + def format(self) -> str: + """Return container format.""" + return 'mpegts' + + @property + def audio_codec(self) -> str: + """Return desired audio codec.""" + return 'aac' + + @property + def video_codec(self) -> str: + """Return desired video codec.""" + return 'h264' diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py new file mode 100644 index 00000000000..3a3e19d9703 --- /dev/null +++ b/homeassistant/components/stream/worker.py @@ -0,0 +1,142 @@ +"""Provides the worker thread needed for processing streams.""" +from fractions import Fraction +import io +import logging + +from .const import AUDIO_SAMPLE_RATE +from .core import Segment, StreamBuffer + +_LOGGER = logging.getLogger(__name__) + + +def generate_audio_frame(): + """Generate a blank audio frame.""" + from av import AudioFrame + audio_frame = AudioFrame(format='dbl', layout='mono', samples=1024) + # audio_bytes = b''.join(b'\x00\x00\x00\x00\x00\x00\x00\x00' + # for i in range(0, 1024)) + audio_bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00' * 1024 + audio_frame.planes[0].update(audio_bytes) + audio_frame.sample_rate = AUDIO_SAMPLE_RATE + audio_frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE) + return audio_frame + + +def create_stream_buffer(stream_output, video_stream, audio_frame): + """Create a new StreamBuffer.""" + import av + a_packet = None + segment = io.BytesIO() + output = av.open( + segment, mode='w', format=stream_output.format) + vstream = output.add_stream( + stream_output.video_codec, video_stream.rate) + # Fix format + vstream.codec_context.format = \ + video_stream.codec_context.format + # Check if audio is requested + astream = None + if stream_output.audio_codec: + astream = output.add_stream( + stream_output.audio_codec, AUDIO_SAMPLE_RATE) + # Need to do it multiple times for some reason + while not a_packet: + a_packets = astream.encode(audio_frame) + if a_packets: + a_packet = a_packets[0] + return (a_packet, StreamBuffer(segment, output, vstream, astream)) + + +def stream_worker(hass, stream, quit_event): + """Handle consuming streams.""" + import av + container = av.open(stream.source, options=stream.options) + try: + video_stream = container.streams.video[0] + except (KeyError, IndexError): + _LOGGER.error("Stream has no video") + return + + audio_frame = generate_audio_frame() + + outputs = {} + first_packet = True + sequence = 1 + audio_packets = {} + + while not quit_event.is_set(): + try: + packet = next(container.demux(video_stream)) + if packet.dts is None: + # If we get a "flushing" packet, the stream is done + raise StopIteration + except (av.AVError, StopIteration) as ex: + # End of stream, clear listeners and stop thread + for fmt, _ in outputs.items(): + hass.loop.call_soon_threadsafe( + stream.outputs[fmt].put, None) + _LOGGER.error("Error demuxing stream: %s", ex) + break + + # Reset segment on every keyframe + if packet.is_keyframe: + # Save segment to outputs + segment_duration = (packet.pts * packet.time_base) / sequence + for fmt, buffer in outputs.items(): + buffer.output.close() + del audio_packets[buffer.astream] + if stream.outputs.get(fmt): + hass.loop.call_soon_threadsafe( + stream.outputs[fmt].put, Segment( + sequence, buffer.segment, segment_duration + )) + + # Clear outputs and increment sequence + outputs = {} + if not first_packet: + sequence += 1 + + # Initialize outputs + for stream_output in stream.outputs.values(): + if video_stream.name != stream_output.video_codec: + continue + + a_packet, buffer = create_stream_buffer( + stream_output, video_stream, audio_frame) + audio_packets[buffer.astream] = a_packet + outputs[stream_output.format] = buffer + + # First video packet tends to have a weird dts/pts + if first_packet: + packet.dts = 0 + packet.pts = 0 + first_packet = False + + # Store packets on each output + for buffer in outputs.values(): + # Check if the format requires audio + if audio_packets.get(buffer.astream): + a_packet = audio_packets[buffer.astream] + a_time_base = a_packet.time_base + + # Determine video start timestamp and duration + video_start = packet.pts * packet.time_base + video_duration = packet.duration * packet.time_base + + if packet.is_keyframe: + # Set first audio packet in sequence to equal video pts + a_packet.pts = int(video_start / a_time_base) + a_packet.dts = int(video_start / a_time_base) + + # Determine target end timestamp for audio + target_pts = int((video_start + video_duration) / a_time_base) + while a_packet.pts < target_pts: + # Mux audio packet and adjust points until target hit + buffer.output.mux(a_packet) + a_packet.pts += a_packet.duration + a_packet.dts += a_packet.duration + audio_packets[buffer.astream] = a_packet + + # Assign the video packet to the new stream & mux + packet.stream = buffer.vstream + buffer.output.mux(packet) diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index a04de7618af..0d9008552a1 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, POWER_WATT, ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE @@ -25,11 +25,11 @@ DEFAULT_HOST = 'fritz.box' ATTR_CURRENT_CONSUMPTION = 'current_consumption' ATTR_CURRENT_CONSUMPTION_UNIT = 'current_consumption_unit' -ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = 'W' +ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR ATTR_TEMPERATURE_UNIT = 'temperature_unit' diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index a0058cb925c..a25517eea91 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -1,9 +1,4 @@ -""" -Support for myStrom switches. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/switch.mystrom/ -""" +"""Support for myStrom switches.""" import logging import voluptuous as vol @@ -12,7 +7,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_HOST) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mystrom==0.4.4'] +REQUIREMENTS = ['python-mystrom==0.5.0'] DEFAULT_NAME = 'myStrom Switch' diff --git a/homeassistant/components/switch/vesync.py b/homeassistant/components/switch/vesync.py index 382096ad5e4..d9ffbf9c12d 100644 --- a/homeassistant/components/switch/vesync.py +++ b/homeassistant/components/switch/vesync.py @@ -11,7 +11,7 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyvesync==0.1.1'] +REQUIREMENTS = ['pyvesync_v2==0.9.6'] _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the VeSync switch platform.""" - from pyvesync.vesync import VeSync + from pyvesync_v2.vesync import VeSync switches = [] @@ -104,5 +104,6 @@ class VeSyncSwitchHA(SwitchDevice): def update(self): """Handle data changes for node values.""" self.smartplug.update() - self._current_power_w = self.smartplug.get_power() - self._today_energy_kwh = self.smartplug.get_kwh_today() + if self.smartplug.devtype == 'outlet': + self._current_power_w = self.smartplug.get_power() + self._today_energy_kwh = self.smartplug.get_kwh_today() diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 767e29ba0b9..56fc0cb704c 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle -REQUIREMENTS = ['python-tado==0.2.3'] +REQUIREMENTS = ['python-tado==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 7812bbd812b..8804bef5616 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -52,9 +52,9 @@ class TadoDeviceScanner(DeviceScanner): # If there's a home_id, we need a different API URL if self.home_id is None: - self.tadoapiurl = 'https://my.tado.com/api/v2/me' + self.tadoapiurl = 'https://auth.tado.com/api/v2/me' else: - self.tadoapiurl = 'https://my.tado.com/api/v2' \ + self.tadoapiurl = 'https://auth.tado.com/api/v2' \ '/homes/{home_id}/mobileDevices' # The API URL always needs a username and password diff --git a/homeassistant/components/tellduslive/.translations/de.json b/homeassistant/components/tellduslive/.translations/de.json index a9f91f16b11..6c094ed6a8c 100644 --- a/homeassistant/components/tellduslive/.translations/de.json +++ b/homeassistant/components/tellduslive/.translations/de.json @@ -19,6 +19,7 @@ "data": { "host": "Host" }, + "description": "Leer", "title": "Endpunkt ausw\u00e4hlen." } }, diff --git a/homeassistant/components/tellduslive/.translations/fr.json b/homeassistant/components/tellduslive/.translations/fr.json index 2dd1c03022a..a20e6bff2b5 100644 --- a/homeassistant/components/tellduslive/.translations/fr.json +++ b/homeassistant/components/tellduslive/.translations/fr.json @@ -10,6 +10,7 @@ "user": { "description": "Vide" } - } + }, + "title": "Telldus Live" } } \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/th.json b/homeassistant/components/tellduslive/.translations/th.json new file mode 100644 index 00000000000..4d01bb3e14c --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/zh-Hans.json b/homeassistant/components/tellduslive/.translations/zh-Hans.json index f707b1f15f8..4b1afd548e8 100644 --- a/homeassistant/components/tellduslive/.translations/zh-Hans.json +++ b/homeassistant/components/tellduslive/.translations/zh-Hans.json @@ -2,8 +2,8 @@ "config": { "abort": { "all_configured": "Tellduslive \u5df2\u914d\u7f6e\u5b8c\u6210", - "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", - "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6", + "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", + "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002", "unknown": "\u53d1\u751f\u672a\u77e5\u7684\u9519\u8bef" }, "error": { @@ -11,13 +11,12 @@ }, "step": { "auth": { - "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1.\u5355\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2.\u767b\u5f55Telldus Live \n 3.\u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4.\u56de\u5230\u8fd9\u91cc\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [TelldusLive\u8d26\u6237\u94fe\u63a5]({auth_url})" + "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1. \u70b9\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2. \u767b\u5f55 Telldus Live \n 3. \u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4. \u8fd4\u56de\u6b64\u9875\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [\u94fe\u63a5 TelldusLive \u8d26\u6237]({auth_url})" }, "user": { "data": { "host": "\u4e3b\u673a" - }, - "description": "\u7a7a\u767d" + } } } } diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 48133fd69e6..42c93aa52f9 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components import sensor, tellduslive from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS) + TEMP_CELSIUS, POWER_WATT) from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ SENSOR_TYPES = { SENSOR_TYPE_WINDAVERAGE: ['Wind average', 'm/s', '', None], SENSOR_TYPE_WINDGUST: ['Wind gust', 'm/s', '', None], SENSOR_TYPE_UV: ['UV', 'UV', '', None], - SENSOR_TYPE_WATT: ['Power', 'W', '', None], + SENSOR_TYPE_WATT: ['Power', POWER_WATT, '', None], SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE], SENSOR_TYPE_DEW_POINT: ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index c6d281772a5..0438ad79abc 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -5,7 +5,7 @@ from collections import namedtuple import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, CONF_ID, CONF_NAME from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -20,15 +20,18 @@ CONF_ONLY_NAMED = 'only_named' CONF_TEMPERATURE_SCALE = 'temperature_scale' DEFAULT_DATATYPE_MASK = 127 -DEFAULT_ONLY_NAMED = False DEFAULT_TEMPERATURE_SCALE = TEMP_CELSIUS PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ONLY_NAMED, default=DEFAULT_ONLY_NAMED): cv.boolean, vol.Optional(CONF_TEMPERATURE_SCALE, default=DEFAULT_TEMPERATURE_SCALE): cv.string, vol.Optional(CONF_DATATYPE_MASK, default=DEFAULT_DATATYPE_MASK): cv.positive_int, + vol.Optional(CONF_ONLY_NAMED, default=[]): + vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + })]) }) @@ -69,20 +72,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] datatype_mask = config.get(CONF_DATATYPE_MASK) + if config[CONF_ONLY_NAMED]: + named_sensors = { + named_sensor[CONF_ID]: named_sensor[CONF_NAME] + for named_sensor in config[CONF_ONLY_NAMED]} + for tellcore_sensor in tellcore_lib.sensors(): - try: - sensor_name = config[tellcore_sensor.id] - except KeyError: - if config.get(CONF_ONLY_NAMED): - continue + if not config[CONF_ONLY_NAMED]: sensor_name = str(tellcore_sensor.id) + else: + if tellcore_sensor.id not in named_sensors: + continue + sensor_name = named_sensors[tellcore_sensor.id] for datatype in sensor_value_descriptions: - if datatype & datatype_mask: - if tellcore_sensor.has_value(datatype): - sensor_info = sensor_value_descriptions[datatype] - sensors.append(TellstickSensor( - sensor_name, tellcore_sensor, datatype, sensor_info)) + if datatype & datatype_mask and \ + tellcore_sensor.has_value(datatype): + sensor_info = sensor_value_descriptions[datatype] + sensors.append(TellstickSensor( + sensor_name, tellcore_sensor, + datatype, sensor_info)) add_entities(sensors) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index fc433ae18b1..244538f5f46 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -REQUIREMENTS = ['teslajsonpy==0.0.23'] +REQUIREMENTS = ['teslajsonpy==0.0.25'] DOMAIN = 'tesla' diff --git a/homeassistant/components/tof/__init__.py b/homeassistant/components/tof/__init__.py new file mode 100644 index 00000000000..0e72aca724b --- /dev/null +++ b/homeassistant/components/tof/__init__.py @@ -0,0 +1 @@ +"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics.""" diff --git a/homeassistant/components/tof/sensor.py b/homeassistant/components/tof/sensor.py new file mode 100644 index 00000000000..a403db03682 --- /dev/null +++ b/homeassistant/components/tof/sensor.py @@ -0,0 +1,125 @@ +"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics.""" + +import asyncio +import logging +from functools import partial + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.components import rpi_gpio +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['VL53L1X2==0.1.5'] + +DEPENDENCIES = ['rpi_gpio'] + +_LOGGER = logging.getLogger(__name__) + +LENGTH_MILLIMETERS = 'mm' + +CONF_I2C_ADDRESS = 'i2c_address' +CONF_I2C_BUS = 'i2c_bus' +CONF_XSHUT = 'xshut' + +DEFAULT_NAME = 'VL53L1X' +DEFAULT_I2C_ADDRESS = 0x29 +DEFAULT_I2C_BUS = 1 +DEFAULT_XSHUT = 16 +DEFAULT_RANGE = 2 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, + default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, + default=DEFAULT_I2C_ADDRESS): vol.Coerce(int), + vol.Optional(CONF_I2C_BUS, + default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional(CONF_XSHUT, + default=DEFAULT_XSHUT): cv.positive_int, +}) + + +def init_tof_0(xshut, sensor): + """XSHUT port LOW resets the device.""" + sensor.open() + rpi_gpio.setup_output(xshut) + rpi_gpio.write_output(xshut, 0) + + +def init_tof_1(xshut): + """XSHUT port HIGH enables the device.""" + rpi_gpio.setup_output(xshut) + rpi_gpio.write_output(xshut, 1) + + +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): + """Reset and initialize the VL53L1X ToF Sensor from STMicroelectronics.""" + from VL53L1X2 import VL53L1X # pylint: disable=import-error + + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + i2c_address = config.get(CONF_I2C_ADDRESS) + unit = LENGTH_MILLIMETERS + xshut = config.get(CONF_XSHUT) + + sensor = await hass.async_add_executor_job( + partial(VL53L1X, bus_number) + ) + await hass.async_add_executor_job( + init_tof_0, xshut, sensor + ) + await asyncio.sleep(0.01) + await hass.async_add_executor_job( + init_tof_1, xshut + ) + await asyncio.sleep(0.01) + + dev = [VL53L1XSensor(sensor, name, unit, i2c_address)] + + async_add_entities(dev, True) + + +class VL53L1XSensor(Entity): + """Implementation of VL53L1X sensor.""" + + def __init__(self, vl53l1x_sensor, name, unit, i2c_address): + """Initialize the sensor.""" + self._name = name + self._unit_of_measurement = unit + self.vl53l1x_sensor = vl53l1x_sensor + self.i2c_address = i2c_address + self._state = None + self.init = True + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Get the latest measurement and update state.""" + if self.init: + self.vl53l1x_sensor.add_sensor( + self.i2c_address, self.i2c_address) + self.init = False + self.vl53l1x_sensor.start_ranging( + self.i2c_address, DEFAULT_RANGE) + self.vl53l1x_sensor.update(self.i2c_address) + self.vl53l1x_sensor.stop_ranging(self.i2c_address) + self._state = self.vl53l1x_sensor.distance diff --git a/homeassistant/components/toon/.translations/da.json b/homeassistant/components/toon/.translations/da.json new file mode 100644 index 00000000000..52bb867d113 --- /dev/null +++ b/homeassistant/components/toon/.translations/da.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn" + } + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/de.json b/homeassistant/components/toon/.translations/de.json new file mode 100644 index 00000000000..cbcfd5d4adc --- /dev/null +++ b/homeassistant/components/toon/.translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Die Client-ID aus der Konfiguration ist ung\u00fcltig.", + "client_secret": "Das Client-Secret aus der Konfiguration ist ung\u00fcltig.", + "no_agreements": "Dieses Konto hat keine Toon-Anzeigen.", + "no_app": "Toon muss konfiguriert werden, bevor die Authentifizierung durchgef\u00fchrt werden kann. [Lies bitte die Anleitung](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Beim Authentifizieren ist ein unerwarteter Fehler aufgetreten." + }, + "error": { + "credentials": "Die angegebenen Anmeldeinformationen sind ung\u00fcltig.", + "display_exists": "Die ausgew\u00e4hlte Anzeige ist bereits konfiguriert." + }, + "step": { + "authenticate": { + "data": { + "password": "Passwort", + "tenant": "Tenant", + "username": "Benutzername" + }, + "description": "Authentifiziere dich mit deinem Eneco Toon-Konto (nicht dem Entwicklerkonto).", + "title": "Verkn\u00fcpfe dein Toon-Konto" + }, + "display": { + "data": { + "display": "Anzeige w\u00e4hlen" + }, + "description": "W\u00e4hle die Toon-Anzeige aus, die verbunden werden soll.", + "title": "Anzeige ausw\u00e4hlen" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/fr.json b/homeassistant/components/toon/.translations/fr.json new file mode 100644 index 00000000000..5bf0c60199e --- /dev/null +++ b/homeassistant/components/toon/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "no_agreements": "Ce compte n'a pas d'affichages Toon.", + "no_app": "Vous devez configurer Toon avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Une erreur inattendue s'est produite lors de l'authentification." + }, + "error": { + "credentials": "Les informations d'identification fournies ne sont pas valides.", + "display_exists": "L'affichage s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "authenticate": { + "data": { + "password": "Mot de passe", + "tenant": "Locataire", + "username": "Nom d'utilisateur" + }, + "description": "Authentifiez-vous avec votre compte Eneco Toon (pas le compte d\u00e9veloppeur).", + "title": "Lier un compte Toon" + }, + "display": { + "data": { + "display": "Choisissez l'affichage" + }, + "description": "S\u00e9lectionnez l'affichage Toon avec lequel vous connecter.", + "title": "S\u00e9lectionnez l'affichage" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/lt.json b/homeassistant/components/toon/.translations/lt.json new file mode 100644 index 00000000000..4c2802218f2 --- /dev/null +++ b/homeassistant/components/toon/.translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Slapta\u017eodis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/nl.json b/homeassistant/components/toon/.translations/nl.json new file mode 100644 index 00000000000..2ca887b1766 --- /dev/null +++ b/homeassistant/components/toon/.translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "De client ID uit de configuratie is ongeldig.", + "client_secret": "De client secret uit de configuratie is ongeldig.", + "no_agreements": "Dit account heeft geen Toon schermen.", + "no_app": "Je moet Toon configureren voordat je ermee kunt aanmelden. [Lees de instructies](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Onverwachte fout tijdens het verifi\u00ebren." + }, + "error": { + "credentials": "De opgegeven inloggegevens zijn ongeldig.", + "display_exists": "Het gekozen scherm is al geconfigureerd." + }, + "step": { + "authenticate": { + "data": { + "password": "Wachtwoord", + "tenant": "Huurder", + "username": "Gebruikersnaam" + }, + "description": "Verifieer met je Eneco Toon account (niet het ontwikkelaars account).", + "title": "Link je Toon-account" + }, + "display": { + "data": { + "display": "Kies scherm" + }, + "description": "Kies het Toon-scherm om mee te verbinden.", + "title": "Kies scherm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/no.json b/homeassistant/components/toon/.translations/no.json new file mode 100644 index 00000000000..37dcd8ac22f --- /dev/null +++ b/homeassistant/components/toon/.translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Klient ID fra konfigurasjonen er ugyldig.", + "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.", + "no_agreements": "Denne kontoen har ingen Toon skjermer.", + "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Uventet feil oppstod under autentisering." + }, + "error": { + "credentials": "De oppgitte legitimasjonene er ugyldige.", + "display_exists": "Den valgte skjermen er allerede konfigurert." + }, + "step": { + "authenticate": { + "data": { + "password": "Passord", + "tenant": "Leietaker", + "username": "Brukernavn" + }, + "description": "Godkjen med Eneco Toon kontoen din (ikke utviklerkontoen).", + "title": "Linken din Toon konto" + }, + "display": { + "data": { + "display": "Velg skjerm" + }, + "description": "Velg Toon skjerm \u00e5 koble til.", + "title": "Velg skjerm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/pl.json b/homeassistant/components/toon/.translations/pl.json new file mode 100644 index 00000000000..26627389ddd --- /dev/null +++ b/homeassistant/components/toon/.translations/pl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.", + "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.", + "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.", + "no_app": "Musisz skonfigurowa\u0107 Toon zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. Prosz\u0119 przeczyta\u0107 instrukcj\u0119] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Wyst\u0105pi\u0142 nieoczekiwany b\u0142\u0105d podczas uwierzytelniania." + }, + "error": { + "credentials": "Wprowadzone dane logowania s\u0105 nieprawid\u0142owe.", + "display_exists": "Wybrany ekran jest ju\u017c skonfigurowany." + }, + "step": { + "authenticate": { + "data": { + "password": "Has\u0142o", + "tenant": "Najemca", + "username": "Nazwa u\u017cytkownika" + }, + "description": "Uwierzytelnij swoje konto Eneco Toon (nie konto programisty).", + "title": "Po\u0142\u0105cz swoje konto Toon" + }, + "display": { + "data": { + "display": "Wybierz wy\u015bwietlacz" + }, + "description": "Wybierz wy\u015bwietlacz Toon, z kt\u00f3rym chcesz si\u0119 po\u0142\u0105czy\u0107.", + "title": "Wybierz wy\u015bwietlacz" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/sv.json b/homeassistant/components/toon/.translations/sv.json new file mode 100644 index 00000000000..4427b90ab9c --- /dev/null +++ b/homeassistant/components/toon/.translations/sv.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Client ID fr\u00e5n konfiguration \u00e4r ogiltig.", + "client_secret": "Client secret fr\u00e5n konfigurationen \u00e4r ogiltig.", + "no_agreements": "Det h\u00e4r kontot har inga Toon-sk\u00e4rmar.", + "no_app": "Du m\u00e5ste konfigurera Toon innan du kan autentisera med den. [L\u00e4s instruktioner] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Ov\u00e4ntat fel uppstod under autentisering." + }, + "error": { + "credentials": "De angivna uppgifterna \u00e4r ogiltiga.", + "display_exists": "Den valda sk\u00e4rmen \u00e4r redan konfigurerad" + }, + "step": { + "authenticate": { + "data": { + "password": "L\u00f6senord", + "tenant": "Hyresg\u00e4st", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Autentisera med ditt Eneco Toon-konto (inte developer-kontot).", + "title": "L\u00e4nk ditt Toon-konto" + }, + "display": { + "data": { + "display": "V\u00e4lj sk\u00e4rm" + }, + "description": "V\u00e4lj Toon-sk\u00e4rm att ansluta till.", + "title": "V\u00e4lj sk\u00e4rm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 0ca0a414fa5..d718b5895e4 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -16,7 +16,7 @@ from .const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN) -REQUIREMENTS = ['toonapilib==3.2.1'] +REQUIREMENTS = ['toonapilib==3.2.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index a50a67085ec..694b7d1d033 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -17,7 +17,7 @@ DEPENDENCIES = ['toon'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 13f1c1269a1..f09dc010c79 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) HA_TOON = { STATE_AUTO: 'Comfort', diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 29b58fbfff9..4d8ccd70e12 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -1,4 +1,6 @@ """Constants for the Toon integration.""" +from homeassistant.const import ENERGY_KILO_WATT_HOUR + DOMAIN = 'toon' DATA_TOON = 'toon' @@ -15,7 +17,7 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = 'EUR' POWER_WATT = 'W' -POWER_KWH = 'kWh' +POWER_KWH = ENERGY_KILO_WATT_HOUR RATIO_PERCENT = '%' VOLUME_CM3 = 'CM3' VOLUME_M3 = 'M3' diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index e263bda9fc7..f58c8ef4840 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -16,7 +16,7 @@ DEPENDENCIES = ['toon'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, diff --git a/homeassistant/components/tplink/.translations/fr.json b/homeassistant/components/tplink/.translations/fr.json new file mode 100644 index 00000000000..7351825398f --- /dev/null +++ b/homeassistant/components/tplink/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun p\u00e9riph\u00e9rique TP-Link trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer TP-Link smart devices?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/nl.json b/homeassistant/components/tplink/.translations/nl.json new file mode 100644 index 00000000000..622315fd84c --- /dev/null +++ b/homeassistant/components/tplink/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen TP-Link apparaten gevonden op het netwerk.", + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie is nodig." + }, + "step": { + "confirm": { + "description": "Wil je TP-Link slimme apparaten instellen?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index bc285150890..9fc12db0d63 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -107,10 +107,15 @@ async def async_setup_entry(hass, config_entry): def _fill_device_lists(): for dev in devices.values(): if isinstance(dev, SmartPlug): - if dev.is_dimmable: # Dimmers act as lights - lights.append(dev) - else: - switches.append(dev) + try: + if dev.is_dimmable: # Dimmers act as lights + lights.append(dev) + else: + switches.append(dev) + except SmartDeviceException as ex: + _LOGGER.error("Unable to connect to device %s: %s", + dev.host, ex) + elif isinstance(dev, SmartBulb): lights.append(dev) else: diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 475ed2c6892..0cd4a1bb6c6 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_MUSIC, SERVICE_PLAY_MEDIA) from homeassistant.components.media_player.const import DOMAIN as DOMAIN_MP -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform @@ -126,7 +126,7 @@ async def async_setup(hass, config): async def async_say_handle(service): """Service handle for say.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) + entity_ids = service.data.get(ATTR_ENTITY_ID, ENTITY_MATCH_ALL) message = service.data.get(ATTR_MESSAGE) cache = service.data.get(ATTR_CACHE) language = service.data.get(ATTR_LANGUAGE) @@ -144,11 +144,9 @@ async def async_setup(hass, config): data = { ATTR_MEDIA_CONTENT_ID: url, ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_ENTITY_ID: entity_ids, } - if entity_ids: - data[ATTR_ENTITY_ID] = entity_ids - await hass.services.async_call( DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) diff --git a/homeassistant/components/unifi/.translations/th.json b/homeassistant/components/unifi/.translations/th.json new file mode 100644 index 00000000000..178d052c722 --- /dev/null +++ b/homeassistant/components/unifi/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c", + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/cs.json b/homeassistant/components/upnp/.translations/cs.json index 58de4963000..17d9949453c 100644 --- a/homeassistant/components/upnp/.translations/cs.json +++ b/homeassistant/components/upnp/.translations/cs.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD je ji\u017e nakonfigurov\u00e1no", "incomplete_device": "Ignorov\u00e1n\u00ed ne\u00fapln\u00e9ho za\u0159\u00edzen\u00ed UPnP", "no_devices_discovered": "Nebyly zji\u0161t\u011bny \u017e\u00e1dn\u00e9 UPnP/IGD", - "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f" + "no_devices_found": "V s\u00edti nejsou nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed UPnP/IGD.", + "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f", + "single_instance_allowed": "Povolena je pouze jedna instance UPnP/IGD." }, "step": { + "confirm": { + "description": "Chcete nastavit UPnP/IGD?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index efa3ee73af8..a9fb84f733e 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -23,7 +23,7 @@ from .const import DOMAIN from .const import LOGGER as _LOGGER from .device import Device -REQUIREMENTS = ['async-upnp-client==0.14.4'] +REQUIREMENTS = ['async-upnp-client==0.14.5'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 2f062851ee6..97321c456e5 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -1,5 +1,6 @@ """Support for tracking consumption over given periods of time.""" import logging +from datetime import timedelta import voluptuous as vol @@ -23,6 +24,8 @@ TARIFF_ICON = 'mdi:clock-outline' ATTR_TARIFFS = 'tariffs' +DEFAULT_OFFSET = timedelta(hours=0) + SERVICE_METER_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -35,7 +38,8 @@ METER_CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), - vol.Optional(CONF_METER_OFFSET, default=0): cv.positive_int, + vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, [cv.string]), diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 21dc1099442..dd1514f5e43 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,6 +1,6 @@ """Utility meter from sensors providing raw data.""" import logging - +from datetime import date, timedelta from decimal import Decimal, DecimalException import homeassistant.util.dt as dt_util @@ -128,15 +128,17 @@ class UtilityMeterSensor(RestoreEntity): self.async_schedule_update_ha_state() async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger then daily cycles.""" - now = dt_util.now() - if self._period == WEEKLY and now.weekday() != self._period_offset: + """Determine cycle - Helper function for larger than daily cycles.""" + now = dt_util.now().date() + if self._period == WEEKLY and\ + now != now - timedelta(days=now.weekday())\ + + self._period_offset: return if self._period == MONTHLY and\ - now.day != (1 + self._period_offset): + now != date(now.year, now.month, 1) + self._period_offset: return if self._period == YEARLY and\ - (now.month != (1 + self._period_offset) or now.day != 1): + now != date(now.year, 1, 1) + self._period_offset: return await self.async_reset_meter(self._tariff_entity) @@ -155,15 +157,16 @@ class UtilityMeterSensor(RestoreEntity): await super().async_added_to_hass() if self._period == HOURLY: - async_track_time_change(self.hass, self._async_reset_meter, - minute=self._period_offset, second=0) - elif self._period == DAILY: - async_track_time_change(self.hass, self._async_reset_meter, - hour=self._period_offset, minute=0, - second=0) - elif self._period in [WEEKLY, MONTHLY, YEARLY]: - async_track_time_change(self.hass, self._async_reset_meter, - hour=0, minute=0, second=0) + async_track_time_change( + self.hass, self._async_reset_meter, + minute=self._period_offset.seconds // 60, + second=self._period_offset.seconds % 60) + elif self._period in [DAILY, WEEKLY, MONTHLY, YEARLY]: + async_track_time_change( + self.hass, self._async_reset_meter, + hour=self._period_offset.seconds // 3600, + minute=self._period_offset.seconds % 3600 // 60, + second=self._period_offset.seconds % 3600 % 60) async_dispatcher_connect( self.hass, SIGNAL_RESET_METER, self.async_reset_meter) diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 38d8b6c3f1c..4e808dc21ca 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -7,7 +7,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_PORT from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-velbus==2.0.21'] +REQUIREMENTS = ['python-velbus==2.0.22'] _LOGGER = logging.getLogger(__name__) @@ -62,7 +62,13 @@ async def async_setup(hass, config): load_platform(hass, 'sensor', DOMAIN, discovery_info['sensor'], config) + def syn_clock(self, service=None): + controller.sync_clock() + controller.scan(callback) + hass.services.async_register( + DOMAIN, 'sync_clock', syn_clock, + schema=vol.Schema({})) return True diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml new file mode 100644 index 00000000000..40916a08418 --- /dev/null +++ b/homeassistant/components/velbus/services.yaml @@ -0,0 +1,2 @@ +sync_clock: + description: Sync the velbus modules clock to the HASS clock, this is the same as the 'sync clock' from VelbusLink diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 6ea50ae6c0d..a46f62dbd5f 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -12,7 +12,7 @@ DATA_VELUX = "data_velux" SUPPORTED_DOMAINS = ['cover', 'scene'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyvlx==0.2.9'] +REQUIREMENTS = ['pyvlx==0.2.10'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 1c3192961af..6abaa42bb9d 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -71,11 +71,11 @@ class VeluxCover(CoverDevice): async def async_close_cover(self, **kwargs): """Close the cover.""" - await self.node.close() + await self.node.close(wait_for_completion=False) async def async_open_cover(self, **kwargs): """Open the cover.""" - await self.node.open() + await self.node.open(wait_for_completion=False) async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -83,8 +83,9 @@ class VeluxCover(CoverDevice): position_percent = 100 - kwargs[ATTR_POSITION] from pyvlx import Position await self.node.set_position( - Position(position_percent=position_percent)) + Position(position_percent=position_percent), + wait_for_completion=False) async def async_stop_cover(self, **kwargs): """Stop the cover.""" - await self.node.stop() + await self.node.stop(wait_for_completion=False) diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index db1e9450daf..b0716dc2cb8 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -29,4 +29,4 @@ class VeluxScene(Scene): async def async_activate(self): """Activate the scene.""" - await self.scene.run() + await self.scene.run(wait_for_completion=False) diff --git a/homeassistant/components/water_heater/econet.py b/homeassistant/components/water_heater/econet.py index efc21798859..90176842bf1 100644 --- a/homeassistant/components/water_heater/econet.py +++ b/homeassistant/components/water_heater/econet.py @@ -13,7 +13,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyeconet==0.0.9'] +REQUIREMENTS = ['pyeconet==0.0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 3734f46abb7..6c4935b9d95 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -14,6 +14,7 @@ ActiveConnection = connection.ActiveConnection BASE_COMMAND_MESSAGE_SCHEMA = messages.BASE_COMMAND_MESSAGE_SCHEMA error_message = messages.error_message result_message = messages.result_message +event_message = messages.event_message async_response = decorators.async_response require_admin = decorators.require_admin ws_require_user = decorators.ws_require_user diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index f175327bf28..dbb43e08780 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -2,11 +2,12 @@ import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import __version__ -from homeassistant.components.http.auth import validate_password -from homeassistant.components.http.ban import process_wrong_login, \ - process_success_login from homeassistant.auth.providers import legacy_api_password +from homeassistant.components.http.ban import ( + process_wrong_login, + process_success_login, +) +from homeassistant.const import __version__ from .connection import ActiveConnection from .error import Disconnect @@ -80,9 +81,15 @@ class AuthPhase: refresh_token.user, refresh_token) elif self._hass.auth.support_legacy and 'api_password' in msg: - self._logger.debug("Received api_password") - if validate_password(self._request, msg['api_password']): - user = await legacy_api_password.async_get_user(self._hass) + self._logger.info( + "Received api_password, it is going to deprecate, please use" + " access_token instead. For instructions, see https://" + "developers.home-assistant.io/docs/en/external_api_websocket" + ".html#authentication-phase" + ) + user = await legacy_api_password.async_validate_password( + self._hass, msg['api_password']) + if user is not None: return await self._async_finish_auth(user, None) self._send_message(auth_invalid_message( diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 3313971e79e..32bbd90aad1 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1,7 +1,9 @@ """Commands part of Websocket API.""" import voluptuous as vol -from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.const import ( + MATCH_ALL, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED) from homeassistant.core import callback, DOMAIN as HASS_DOMAIN from homeassistant.exceptions import Unauthorized, ServiceNotFound, \ HomeAssistantError @@ -10,112 +12,78 @@ from homeassistant.helpers.service import async_get_all_descriptions from . import const, decorators, messages -TYPE_CALL_SERVICE = 'call_service' -TYPE_EVENT = 'event' -TYPE_GET_CONFIG = 'get_config' -TYPE_GET_SERVICES = 'get_services' -TYPE_GET_STATES = 'get_states' -TYPE_PING = 'ping' -TYPE_PONG = 'pong' -TYPE_SUBSCRIBE_EVENTS = 'subscribe_events' -TYPE_UNSUBSCRIBE_EVENTS = 'unsubscribe_events' - @callback def async_register_commands(hass): """Register commands.""" async_reg = hass.components.websocket_api.async_register_command - async_reg(TYPE_SUBSCRIBE_EVENTS, handle_subscribe_events, - SCHEMA_SUBSCRIBE_EVENTS) - async_reg(TYPE_UNSUBSCRIBE_EVENTS, handle_unsubscribe_events, - SCHEMA_UNSUBSCRIBE_EVENTS) - async_reg(TYPE_CALL_SERVICE, handle_call_service, SCHEMA_CALL_SERVICE) - async_reg(TYPE_GET_STATES, handle_get_states, SCHEMA_GET_STATES) - async_reg(TYPE_GET_SERVICES, handle_get_services, SCHEMA_GET_SERVICES) - async_reg(TYPE_GET_CONFIG, handle_get_config, SCHEMA_GET_CONFIG) - async_reg(TYPE_PING, handle_ping, SCHEMA_PING) - - -SCHEMA_SUBSCRIBE_EVENTS = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_SUBSCRIBE_EVENTS, - vol.Optional('event_type', default=MATCH_ALL): str, -}) - - -SCHEMA_UNSUBSCRIBE_EVENTS = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_UNSUBSCRIBE_EVENTS, - vol.Required('subscription'): cv.positive_int, -}) - - -SCHEMA_CALL_SERVICE = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_CALL_SERVICE, - vol.Required('domain'): str, - vol.Required('service'): str, - vol.Optional('service_data'): dict -}) - - -SCHEMA_GET_STATES = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_STATES, -}) - - -SCHEMA_GET_SERVICES = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_SERVICES, -}) - - -SCHEMA_GET_CONFIG = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_CONFIG, -}) - - -SCHEMA_PING = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_PING, -}) - - -def event_message(iden, event): - """Return an event message.""" - return { - 'id': iden, - 'type': TYPE_EVENT, - 'event': event.as_dict(), - } + async_reg(handle_subscribe_events) + async_reg(handle_unsubscribe_events) + async_reg(handle_call_service) + async_reg(handle_get_states) + async_reg(handle_get_services) + async_reg(handle_get_config) + async_reg(handle_ping) def pong_message(iden): """Return a pong message.""" return { 'id': iden, - 'type': TYPE_PONG, + 'type': 'pong', } @callback +@decorators.websocket_command({ + vol.Required('type'): 'subscribe_events', + vol.Optional('event_type', default=MATCH_ALL): str, +}) def handle_subscribe_events(hass, connection, msg): """Handle subscribe events command. Async friendly. """ - if not connection.user.is_admin: + from .permissions import SUBSCRIBE_WHITELIST + + event_type = msg['event_type'] + + if (event_type not in SUBSCRIBE_WHITELIST and + not connection.user.is_admin): raise Unauthorized - async def forward_events(event): - """Forward events to websocket.""" - if event.event_type == EVENT_TIME_CHANGED: - return + if event_type == EVENT_STATE_CHANGED: + @callback + def forward_events(event): + """Forward state changed events to websocket.""" + if not connection.user.permissions.check_entity( + event.data['entity_id'], POLICY_READ): + return - connection.send_message(event_message(msg['id'], event)) + connection.send_message(messages.event_message(msg['id'], event)) - connection.event_listeners[msg['id']] = hass.bus.async_listen( - msg['event_type'], forward_events) + else: + @callback + def forward_events(event): + """Forward events to websocket.""" + if event.event_type == EVENT_TIME_CHANGED: + return + + connection.send_message(messages.event_message( + msg['id'], event.as_dict() + )) + + connection.subscriptions[msg['id']] = hass.bus.async_listen( + event_type, forward_events) connection.send_message(messages.result_message(msg['id'])) @callback +@decorators.websocket_command({ + vol.Required('type'): 'unsubscribe_events', + vol.Required('subscription'): cv.positive_int, +}) def handle_unsubscribe_events(hass, connection, msg): """Handle unsubscribe events command. @@ -123,8 +91,8 @@ def handle_unsubscribe_events(hass, connection, msg): """ subscription = msg['subscription'] - if subscription in connection.event_listeners: - connection.event_listeners.pop(subscription)() + if subscription in connection.subscriptions: + connection.subscriptions.pop(subscription)() connection.send_message(messages.result_message(msg['id'])) else: connection.send_message(messages.error_message( @@ -132,6 +100,12 @@ def handle_unsubscribe_events(hass, connection, msg): @decorators.async_response +@decorators.websocket_command({ + vol.Required('type'): 'call_service', + vol.Required('domain'): str, + vol.Required('service'): str, + vol.Optional('service_data'): dict +}) async def handle_call_service(hass, connection, msg): """Handle call service command. @@ -161,6 +135,9 @@ async def handle_call_service(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'get_states', +}) def handle_get_states(hass, connection, msg): """Handle get states command. @@ -177,6 +154,9 @@ def handle_get_states(hass, connection, msg): @decorators.async_response +@decorators.websocket_command({ + vol.Required('type'): 'get_services', +}) async def handle_get_services(hass, connection, msg): """Handle get services command. @@ -188,6 +168,9 @@ async def handle_get_services(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'get_config', +}) def handle_get_config(hass, connection, msg): """Handle get config command. @@ -198,6 +181,9 @@ def handle_get_config(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'ping', +}) def handle_ping(hass, connection, msg): """Handle ping command. diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 041aad3969e..d65ba4c54d8 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -21,7 +21,7 @@ class ActiveConnection: else: self.refresh_token_id = None - self.event_listeners = {} + self.subscriptions = {} self.last_id = 0 def context(self, msg): @@ -82,7 +82,7 @@ class ActiveConnection: @callback def async_close(self): """Close down connection.""" - for unsub in self.event_listeners.values(): + for unsub in self.subscriptions.values(): unsub() @callback diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index d616b6ad670..c0f899d279e 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -40,3 +40,12 @@ def error_message(iden, code, message): 'message': message, }, } + + +def event_message(iden, event): + """Return an event message.""" + return { + 'id': iden, + 'type': 'event', + 'event': event, + } diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py new file mode 100644 index 00000000000..b98b21d184e --- /dev/null +++ b/homeassistant/components/websocket_api/permissions.py @@ -0,0 +1,23 @@ +"""Permission constants for the websocket API. + +Separate file to avoid circular imports. +""" +from homeassistant.const import ( + EVENT_COMPONENT_LOADED, + EVENT_SERVICE_REGISTERED, + EVENT_SERVICE_REMOVED, + EVENT_STATE_CHANGED, + EVENT_THEMES_UPDATED) +from homeassistant.components.persistent_notification import ( + EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + +# These are events that do not contain any sensitive data +# Except for state_changed, which is handled accordingly. +SUBSCRIBE_WHITELIST = { + EVENT_COMPONENT_LOADED, + EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, + EVENT_SERVICE_REGISTERED, + EVENT_SERVICE_REMOVED, + EVENT_STATE_CHANGED, + EVENT_THEMES_UPDATED, +} diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bf743eaf370..844246528a6 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -30,11 +30,11 @@ def setup(hass, config): zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE) - requires_api_password = hass.config.api.api_password is not None params = { 'version': __version__, 'base_url': hass.config.api.base_url, - 'requires_api_password': requires_api_password, + # always needs authentication + 'requires_api_password': True, } host_ip = util.get_local_ip() diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index cafbae13421..82efd564742 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -23,16 +23,17 @@ from .core.const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, - DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, + DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DATA_ZHA_GATEWAY, DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS) from .core.gateway import establish_device_mappings from .core.channels.registry import populate_channel_registry +from .core.store import async_get_registry REQUIREMENTS = [ 'bellows-homeassistant==0.7.1', 'zigpy-homeassistant==0.3.0', 'zigpy-xbee-homeassistant==0.1.2', - 'zha-quirks==0.0.6', + 'zha-quirks==0.0.7', 'zigpy-deconz==0.1.2' ] @@ -146,7 +147,8 @@ async def async_setup_entry(hass, config_entry): ClusterPersistingListener ) - zha_gateway = ZHAGateway(hass, config) + zha_storage = await async_get_registry(hass) + zha_gateway = ZHAGateway(hass, config, zha_storage) # Patch handle_message until zigpy can provide an event here def handle_message(sender, is_reply, profile, cluster, @@ -192,11 +194,14 @@ async def async_setup_entry(hass, config_entry): api.async_load_api(hass, application_controller, zha_gateway) - def zha_shutdown(event): - """Close radio.""" + async def async_zha_shutdown(event): + """Handle shutdown tasks.""" + await hass.data[DATA_ZHA][ + DATA_ZHA_GATEWAY].async_update_device_storage() hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() - hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, zha_shutdown) + hass.bus.async_listen_once( + ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown) return True diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f0739f9a073..6d79f3b3320 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -5,15 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio import logging import voluptuous as vol from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import async_get_registry from .core.const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, - CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID) + CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID, + DATA_ZHA_GATEWAY, DATA_ZHA) +from .core.helpers import get_matched_clusters, async_is_bindable_target _LOGGER = logging.getLogger(__name__) @@ -26,11 +30,18 @@ DEVICE_INFO = 'device_info' ATTR_DURATION = 'duration' ATTR_IEEE_ADDRESS = 'ieee_address' ATTR_IEEE = 'ieee' +ATTR_SOURCE_IEEE = 'source_ieee' +ATTR_TARGET_IEEE = 'target_ieee' +BIND_REQUEST = 0x0021 +UNBIND_REQUEST = 0x0022 SERVICE_PERMIT = 'permit' SERVICE_REMOVE = 'remove' SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute' SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command' +SERVICE_DIRECT_ZIGBEE_BIND = 'issue_direct_zigbee_bind' +SERVICE_DIRECT_ZIGBEE_UNBIND = 'issue_direct_zigbee_unbind' +SERVICE_ZIGBEE_BIND = 'service_zigbee_bind' IEEE_SERVICE = 'ieee_based_service' SERVICE_SCHEMAS = { @@ -62,53 +73,317 @@ SERVICE_SCHEMAS = { }), } -WS_RECONFIGURE_NODE = 'zha/devices/reconfigure' -SCHEMA_WS_RECONFIGURE_NODE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_RECONFIGURE_NODE, + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices' +}) +async def websocket_get_devices(hass, connection, msg): + """Get ZHA devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ha_device_registry = await async_get_registry(hass) + + devices = [] + for device in zha_gateway.devices.values(): + ret_device = {} + ret_device.update(device.device_info) + ret_device['entities'] = [{ + 'entity_id': entity_ref.reference_id, + NAME: entity_ref.device_info[NAME] + } for entity_ref in zha_gateway.device_registry[device.ieee]] + + reg_device = ha_device_registry.async_get_device( + {(DOMAIN, str(device.ieee))}, set()) + if reg_device is not None: + ret_device['user_given_name'] = reg_device.name_by_user + ret_device['device_reg_id'] = reg_device.id + + devices.append(ret_device) + + connection.send_result(msg[ID], devices) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/reconfigure', vol.Required(ATTR_IEEE): str }) +async def websocket_reconfigure_node(hass, connection, msg): + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + device = zha_gateway.get_device(ieee) + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + hass.async_create_task(device.async_configure()) -WS_DEVICES = 'zha/devices' -SCHEMA_WS_LIST_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICES, -}) -WS_DEVICE_CLUSTERS = 'zha/devices/clusters' -SCHEMA_WS_CLUSTERS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTERS, +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters', vol.Required(ATTR_IEEE): str }) +async def websocket_device_clusters(hass, connection, msg): + """Return a list of device clusters.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[IN].items(): + response_clusters.append({ + TYPE: IN, + ID: c_id, + NAME: cluster.__class__.__name__, + 'endpoint_id': ep_id + }) + for c_id, cluster in clusters[OUT].items(): + response_clusters.append({ + TYPE: OUT, + ID: c_id, + NAME: cluster.__class__.__name__, + 'endpoint_id': ep_id + }) -WS_DEVICE_CLUSTER_ATTRIBUTES = 'zha/devices/clusters/attributes' -SCHEMA_WS_CLUSTER_ATTRIBUTES = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTER_ATTRIBUTES, - vol.Required(ATTR_IEEE): str, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str - }) + connection.send_result(msg[ID], response_clusters) -WS_READ_CLUSTER_ATTRIBUTE = 'zha/devices/clusters/attributes/value' -SCHEMA_WS_READ_CLUSTER_ATTRIBUTE = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_READ_CLUSTER_ATTRIBUTE, - vol.Required(ATTR_IEEE): str, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): object, - }) -WS_DEVICE_CLUSTER_COMMANDS = 'zha/devices/clusters/commands' -SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTER_COMMANDS, +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/attributes', vol.Required(ATTR_IEEE): str, vol.Required(ATTR_ENDPOINT_ID): int, vol.Required(ATTR_CLUSTER_ID): int, vol.Required(ATTR_CLUSTER_TYPE): str }) +async def websocket_device_cluster_attributes(hass, connection, msg): + """Return a list of cluster attributes.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + cluster_attributes = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, + cluster_id, + cluster_type) + if attributes is not None: + for attr_id in attributes: + cluster_attributes.append( + { + ID: attr_id, + NAME: attributes[attr_id][0] + } + ) + _LOGGER.debug("Requested attributes for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(RESPONSE, cluster_attributes) + ) + + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/commands', + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str +}) +async def websocket_device_cluster_commands(hass, connection, msg): + """Return a list of cluster commands.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + zha_device = zha_gateway.get_device(ieee) + cluster_commands = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, + cluster_id, + cluster_type) + + if commands is not None: + for cmd_id in commands[CLIENT_COMMANDS]: + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + NAME: commands[CLIENT_COMMANDS][cmd_id][0] + } + ) + for cmd_id in commands[SERVER_COMMANDS]: + cluster_commands.append( + { + TYPE: SERVER, + ID: cmd_id, + NAME: commands[SERVER_COMMANDS][cmd_id][0] + } + ) + _LOGGER.debug("Requested commands for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(RESPONSE, cluster_commands) + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/attributes/value', + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): object, +}) +async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): + """Read zigbee attribute for cluster on zha entity.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + attribute = msg[ATTR_ATTRIBUTE] + manufacturer = msg.get(ATTR_MANUFACTURER) or None + zha_device = zha_gateway.get_device(ieee) + success = failure = None + if zha_device is not None: + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type) + success, failure = await cluster.read_attributes( + [attribute], + allow_cache=False, + only_cache=False, + manufacturer=manufacturer + ) + _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, str(success.get(attribute))), + "{}: [{}]".format('failure', failure) + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/bindable', + vol.Required(ATTR_IEEE): str, +}) +async def websocket_get_bindable_devices(hass, connection, msg): + """Directly bind devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + devices = [ + { + **device.device_info + } for device in zha_gateway.devices.values() if + async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug("Get bindable devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format('bindable devices:', devices) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + devices + )) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/bind', + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) +async def websocket_bind_devices(hass, connection, msg): + """Directly bind devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, BIND_REQUEST) + _LOGGER.info("Issue bind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/unbind', + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) +async def websocket_unbind_devices(hass, connection, msg): + """Remove a direct binding between devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, UNBIND_REQUEST) + _LOGGER.info("Issue unbind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + +async def async_binding_operation(zha_gateway, source_ieee, target_ieee, + operation): + """Create or remove a direct zigbee binding between 2 devices.""" + from zigpy.zdo import types as zdo_types + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + clusters_to_bind = await get_matched_clusters(source_device, + target_device) + + bind_tasks = [] + for cluster_pair in clusters_to_bind: + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = 3 + destination_address.ieee = target_device.ieee + destination_address.endpoint = \ + cluster_pair.target_cluster.endpoint.endpoint_id + + zdo = cluster_pair.source_cluster.endpoint.device.zdo + + _LOGGER.debug("processing binding operation for: %s %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee), + "{}: {}".format( + 'cluster', + cluster_pair.source_cluster.cluster_id) + ) + bind_tasks.append(zdo.request( + operation, + source_device.ieee, + cluster_pair.source_cluster.endpoint.endpoint_id, + cluster_pair.source_cluster.cluster_id, + destination_address + )) + await asyncio.gather(*bind_tasks) def async_load_api(hass, application_controller, zha_gateway): @@ -208,204 +483,18 @@ def async_load_api(hass, application_controller, zha_gateway): SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND ]) - @websocket_api.async_response - async def websocket_get_devices(hass, connection, msg): - """Get ZHA devices.""" - devices = [ - { - **device.device_info, - 'entities': [{ - 'entity_id': entity_ref.reference_id, - NAME: entity_ref.device_info[NAME] - } for entity_ref in zha_gateway.device_registry[device.ieee]] - } for device in zha_gateway.devices.values() - ] - - connection.send_message(websocket_api.result_message( - msg[ID], - devices - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICES, websocket_get_devices, - SCHEMA_WS_LIST_DEVICES - ) - - @websocket_api.async_response - async def websocket_reconfigure_node(hass, connection, msg): - """Reconfigure a ZHA nodes entities by its ieee address.""" - ieee = msg[ATTR_IEEE] - device = zha_gateway.get_device(ieee) - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - hass.async_create_task(device.async_configure()) - - hass.components.websocket_api.async_register_command( - WS_RECONFIGURE_NODE, websocket_reconfigure_node, - SCHEMA_WS_RECONFIGURE_NODE - ) - - @websocket_api.async_response - async def websocket_device_clusters(hass, connection, msg): - """Return a list of device clusters.""" - ieee = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[IN].items(): - response_clusters.append({ - TYPE: IN, - ID: c_id, - NAME: cluster.__class__.__name__, - 'endpoint_id': ep_id - }) - for c_id, cluster in clusters[OUT].items(): - response_clusters.append({ - TYPE: OUT, - ID: c_id, - NAME: cluster.__class__.__name__, - 'endpoint_id': ep_id - }) - - connection.send_message(websocket_api.result_message( - msg[ID], - response_clusters - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTERS, websocket_device_clusters, - SCHEMA_WS_CLUSTERS - ) - - @websocket_api.async_response - async def websocket_device_cluster_attributes(hass, connection, msg): - """Return a list of cluster attributes.""" - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - cluster_attributes = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, - cluster_id, - cluster_type) - if attributes is not None: - for attr_id in attributes: - cluster_attributes.append( - { - ID: attr_id, - NAME: attributes[attr_id][0] - } - ) - _LOGGER.debug("Requested attributes for: %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(RESPONSE, cluster_attributes) - ) - - connection.send_message(websocket_api.result_message( - msg[ID], - cluster_attributes - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTER_ATTRIBUTES, websocket_device_cluster_attributes, - SCHEMA_WS_CLUSTER_ATTRIBUTES - ) - - @websocket_api.async_response - async def websocket_device_cluster_commands(hass, connection, msg): - """Return a list of cluster commands.""" - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - zha_device = zha_gateway.get_device(ieee) - cluster_commands = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, - cluster_id, - cluster_type) - - if commands is not None: - for cmd_id in commands[CLIENT_COMMANDS]: - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - NAME: commands[CLIENT_COMMANDS][cmd_id][0] - } - ) - for cmd_id in commands[SERVER_COMMANDS]: - cluster_commands.append( - { - TYPE: SERVER, - ID: cmd_id, - NAME: commands[SERVER_COMMANDS][cmd_id][0] - } - ) - _LOGGER.debug("Requested commands for: %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(RESPONSE, cluster_commands) - ) - - connection.send_message(websocket_api.result_message( - msg[ID], - cluster_commands - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTER_COMMANDS, websocket_device_cluster_commands, - SCHEMA_WS_CLUSTER_COMMANDS - ) - - @websocket_api.async_response - async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): - """Read zigbee attribute for cluster on zha entity.""" - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - attribute = msg[ATTR_ATTRIBUTE] - manufacturer = msg.get(ATTR_MANUFACTURER) or None - zha_device = zha_gateway.get_device(ieee) - success = failure = None - if zha_device is not None: - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type) - success, failure = await cluster.read_attributes( - [attribute], - allow_cache=False, - only_cache=False, - manufacturer=manufacturer - ) - _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), - "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), - "{}: [{}]".format(RESPONSE, str(success.get(attribute))), - "{}: [{}]".format('failure', failure) - ) - connection.send_message(websocket_api.result_message( - msg[ID], - str(success.get(attribute)) - )) - - hass.components.websocket_api.async_register_command( - WS_READ_CLUSTER_ATTRIBUTE, websocket_read_zigbee_cluster_attributes, - SCHEMA_WS_READ_CLUSTER_ATTRIBUTE - ) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command( + hass, websocket_device_cluster_attributes) + websocket_api.async_register_command( + hass, websocket_device_cluster_commands) + websocket_api.async_register_command( + hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) def async_unload_api(hass): diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index a46ffdd305d..7c08c758af2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -7,12 +7,14 @@ at https://home-assistant.io/components/binary_sensor.zha/ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, LEVEL_CHANNEL, ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, ZONE, OCCUPANCY, - ATTR_LEVEL, SENSOR_TYPE) + ATTR_LEVEL, SENSOR_TYPE, ACCELERATION) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -41,6 +43,7 @@ DEVICE_CLASS_REGISTRY = { OPENING: OPENING, ZONE: get_ias_device_class, OCCUPANCY: OCCUPANCY, + ACCELERATION: 'moving', } @@ -125,6 +128,14 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): await self.async_accept_signal( self._attr_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + super().async_restore_last_state(last_state) + self._state = last_state.state == STATE_ON + if 'level' in last_state.attributes: + self._level = last_state.attributes['level'] + @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" @@ -165,3 +176,21 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): ATTR_LEVEL: self._state and self._level or 0 }) return self._device_state_attributes + + async def async_update(self): + """Attempt to retrieve on off state from the binary sensor.""" + await super().async_update() + if self._level_channel: + self._level = await self._level_channel.get_attribute_value( + 'current_level') + if self._on_off_channel: + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') + if self._zone_channel: + value = await self._zone_channel.get_attribute_value( + 'zone_status') + if value is not None: + self._state = value & 3 + if self._attr_channel: + self._state = await self._attr_channel.get_attribute_value( + self._attr_channel.value_attribute) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a070343b775..92518bd33ff 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -18,9 +18,13 @@ from ..helpers import ( safe_read, get_attr_id_by_name) from ..const import ( CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, - ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL + ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL, ZDO_CHANNEL ) +NODE_DESCRIPTOR_REQUEST = 0x0002 +MAINS_POWERED = 1 +BATTERY_OR_UNKNOWN = 0 + ZIGBEE_CHANNEL_REGISTRY = {} _LOGGER = logging.getLogger(__name__) @@ -181,11 +185,16 @@ class ZigbeeChannel: async def get_attribute_value(self, attribute, from_cache=True): """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._zha_device.manufacturer_code + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code result = await safe_read( self._cluster, [attribute], allow_cache=from_cache, - only_cache=from_cache + only_cache=from_cache, + manufacturer=manufacturer ) return result.get(attribute) @@ -211,14 +220,14 @@ class AttributeListeningChannel(ZigbeeChannel): self.name = ATTRIBUTE_CHANNEL attr = self._report_config[0].get('attr') if isinstance(attr, str): - self._value_attribute = get_attr_id_by_name(self.cluster, attr) + self.value_attribute = get_attr_id_by_name(self.cluster, attr) else: - self._value_attribute = attr + self.value_attribute = attr @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == self._value_attribute: + if attrid == self.value_attribute: async_dispatcher_send( self._zha_device.hass, "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), @@ -235,14 +244,21 @@ class AttributeListeningChannel(ZigbeeChannel): class ZDOChannel: """Channel for ZDO events.""" + POWER_SOURCES = { + MAINS_POWERED: 'Mains', + BATTERY_OR_UNKNOWN: 'Battery or Unknown' + } + def __init__(self, cluster, device): """Initialize ZDOChannel.""" - self.name = 'zdo' + self.name = ZDO_CHANNEL self._cluster = cluster self._zha_device = device self._status = ChannelStatus.CREATED self._unique_id = "{}_ZDO".format(device.name) self._cluster.add_listener(self) + self.power_source = None + self.manufacturer_code = None @property def unique_id(self): @@ -271,10 +287,52 @@ class ZDOChannel: async def async_initialize(self, from_cache): """Initialize channel.""" + entry = self._zha_device.gateway.zha_storage.async_get_or_create( + self._zha_device) + _LOGGER.debug("entry loaded from storage: %s", entry) + if entry is not None: + self.power_source = entry.power_source + self.manufacturer_code = entry.manufacturer_code + + if self.power_source is None: + self.power_source = BATTERY_OR_UNKNOWN + + if self.manufacturer_code is None and not from_cache: + # this should always be set. This is from us not doing + # this previously so lets set it up so users don't have + # to reconfigure every device. + await self.async_get_node_descriptor(False) + entry = self._zha_device.gateway.zha_storage.async_update( + self._zha_device) + _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED + async def async_get_node_descriptor(self, from_cache): + """Request the node descriptor from the device.""" + from zigpy.zdo.types import Status + + if from_cache: + return + + node_descriptor = await self._cluster.request( + NODE_DESCRIPTOR_REQUEST, + self._cluster.device.nwk, tries=3, delay=2) + + def get_bit(byteval, idx): + return int(((byteval & (1 << idx)) != 0)) + + if node_descriptor is not None and\ + node_descriptor[0] == Status.SUCCESS: + mac_capability_flags = node_descriptor[2].mac_capability_flags + + self.power_source = get_bit(mac_capability_flags, 2) + self.manufacturer_code = node_descriptor[2].manufacturer_code + + _LOGGER.debug("node descriptor: %s", node_descriptor) + async def async_configure(self): """Configure channel.""" + await self.async_get_node_descriptor(False) self._status = ChannelStatus.CONFIGURED diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 621b0ccbee1..cd16fe5d22e 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -64,6 +64,13 @@ class OnOffChannel(ZigbeeChannel): await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) await super().async_initialize(from_cache) + async def async_update(self): + """Initialize channel.""" + _LOGGER.debug("Attempting to update onoff state") + self._state = bool( + await self.get_attribute_value(self.ON_OFF, from_cache=False)) + await super().async_update() + class LevelControlChannel(ZigbeeChannel): """Channel for the LevelControl Zigbee cluster.""" diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py index f0363ac8330..8f7335d82a9 100644 --- a/homeassistant/components/zha/core/channels/registry.py +++ b/homeassistant/components/zha/core/channels/registry.py @@ -43,4 +43,5 @@ def populate_channel_registry(): zcl.clusters.general.Basic.cluster_id: BasicChannel, zcl.clusters.security.IasZone.cluster_id: IASZoneChannel, zcl.clusters.hvac.Fan.cluster_id: FanChannel, + zcl.clusters.lightlink.LightLink.cluster_id: ZigbeeChannel, }) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index d1001682c7b..33376b056c6 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -14,6 +14,7 @@ DATA_ZHA_RADIO = 'zha_radio' DATA_ZHA_DISPATCHERS = 'zha_dispatchers' DATA_ZHA_CORE_COMPONENT = 'zha_core_component' DATA_ZHA_CORE_EVENTS = 'zha_core_events' +DATA_ZHA_GATEWAY = 'zha_gateway' ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' COMPONENTS = [ @@ -67,9 +68,11 @@ UNKNOWN = 'unknown' OPENING = 'opening' ZONE = 'zone' OCCUPANCY = 'occupancy' +ACCELERATION = 'acceleration' ATTR_LEVEL = 'level' +ZDO_CHANNEL = 'zdo' ON_OFF_CHANNEL = 'on_off' ATTRIBUTE_CHANNEL = 'attribute' BASIC_CHANNEL = 'basic' @@ -90,6 +93,8 @@ SIGNAL_REMOVE = 'remove' QUIRK_APPLIED = 'quirk_applied' QUIRK_CLASS = 'quirk_class' +MANUFACTURER_CODE = 'manufacturer_code' +POWER_SOURCE = 'power_source' class RadioType(enum.Enum): @@ -114,6 +119,7 @@ CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} EVENT_RELAY_CLUSTERS = [] NO_SENSOR_CLUSTERS = [] +BINDABLE_CLUSTERS = [] REPORT_CONFIG_MAX_INT = 900 REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 102c9bed2d3..0ddb67484c6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,10 +17,9 @@ from .const import ( ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, - QUIRK_CLASS, BASIC_CHANNEL + QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE ) -from .channels import EventRelayChannel -from .channels.general import BasicChannel +from .channels import EventRelayChannel, ZDOChannel _LOGGER = logging.getLogger(__name__) @@ -49,8 +48,8 @@ class ZHADevice: self._model = zigpy_device.endpoints[ept_id].model self._zha_gateway = zha_gateway self.cluster_channels = {} - self._relay_channels = [] - self._all_channels = [] + self._relay_channels = {} + self._all_channels = {} self._name = "{} {}".format( self.manufacturer, self.model @@ -69,7 +68,7 @@ class ZHADevice: self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) - self.power_source = None + self._power_source = None self.status = DeviceStatus.CREATED @property @@ -84,12 +83,12 @@ class ZHADevice: @property def manufacturer(self): - """Return ieee address for device.""" + """Return manufacturer for device.""" return self._manufacturer @property def model(self): - """Return ieee address for device.""" + """Return model for device.""" return self._model @property @@ -115,7 +114,17 @@ class ZHADevice: @property def manufacturer_code(self): """Return manufacturer code for device.""" - # will eventually get this directly from Zigpy + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).manufacturer_code + return None + + @property + def power_source(self): + """Return the power source for the device.""" + if self._power_source is not None: + return self._power_source + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).power_source return None @property @@ -126,7 +135,7 @@ class ZHADevice: @property def all_channels(self): """Return cluster channels and relay channels for device.""" - return self._all_channels + return self._all_channels.values() @property def available_signal(self): @@ -138,6 +147,14 @@ class ZHADevice: """Return True if sensor is available.""" return self._available + def set_available(self, available): + """Set availability from restore and prevent signals.""" + self._available = available + + def set_power_source(self, power_source): + """Set the power source.""" + self._power_source = power_source + def update_available(self, available): """Set sensor availability.""" if self._available != available and available: @@ -164,7 +181,9 @@ class ZHADevice: MODEL: self.model, NAME: self.name or ieee, QUIRK_APPLIED: self.quirk_applied, - QUIRK_CLASS: self.quirk_class + QUIRK_CLASS: self.quirk_class, + MANUFACTURER_CODE: self.manufacturer_code, + POWER_SOURCE: ZDOChannel.POWER_SOURCES.get(self.power_source) } def add_cluster_channel(self, cluster_channel): @@ -173,50 +192,62 @@ class ZHADevice: if cluster_channel.name is POWER_CONFIGURATION_CHANNEL and \ POWER_CONFIGURATION_CHANNEL in self.cluster_channels: return - self._all_channels.append(cluster_channel) + if isinstance(cluster_channel, EventRelayChannel): - self._relay_channels.append(cluster_channel) + self._relay_channels[cluster_channel.unique_id] = cluster_channel + self._all_channels[cluster_channel.unique_id] = cluster_channel else: self.cluster_channels[cluster_channel.name] = cluster_channel + self._all_channels[cluster_channel.name] = cluster_channel async def async_configure(self): """Configure the device.""" _LOGGER.debug('%s: started configuration', self.name) await self._execute_channel_tasks('async_configure') _LOGGER.debug('%s: completed configuration', self.name) + entry = self.gateway.zha_storage.async_create_or_update(self) + _LOGGER.debug('%s: stored in registry: %s', self.name, entry) async def async_initialize(self, from_cache=False): """Initialize channels.""" _LOGGER.debug('%s: started initialization', self.name) await self._execute_channel_tasks('async_initialize', from_cache) - if BASIC_CHANNEL in self.cluster_channels: - self.power_source = self.cluster_channels.get( - BASIC_CHANNEL).get_power_source() - _LOGGER.debug( - '%s: power source: %s', - self.name, - BasicChannel.POWER_SOURCES.get(self.power_source) - ) + _LOGGER.debug( + '%s: power source: %s', + self.name, + ZDOChannel.POWER_SOURCES.get(self.power_source) + ) self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) async def _execute_channel_tasks(self, task_name, *args): """Gather and execute a set of CHANNEL tasks.""" channel_tasks = [] + semaphore = asyncio.Semaphore(3) + zdo_task = None for channel in self.all_channels: - channel_tasks.append( - self._async_create_task(channel, task_name, *args)) + if channel.name == ZDO_CHANNEL: + # pylint: disable=E1111 + zdo_task = self._async_create_task( + semaphore, channel, task_name, *args) + else: + channel_tasks.append( + self._async_create_task( + semaphore, channel, task_name, *args)) + if zdo_task is not None: + await zdo_task await asyncio.gather(*channel_tasks) - async def _async_create_task(self, channel, func_name, *args): + async def _async_create_task(self, semaphore, channel, func_name, *args): """Configure a single channel on this device.""" try: - await getattr(channel, func_name)(*args) - _LOGGER.debug('%s: channel: %s %s stage succeeded', - self.name, - "{}-{}".format( - channel.name, channel.unique_id), - func_name) + async with semaphore: + await getattr(channel, func_name)(*args) + _LOGGER.debug('%s: channel: %s %s stage succeeded', + self.name, + "{}-{}".format( + channel.name, channel.unique_id), + func_name) except Exception as ex: # pylint: disable=broad-except _LOGGER.warning( '%s channel: %s %s stage failed ex: %s', @@ -231,6 +262,11 @@ class ZHADevice: if self._unsub: self._unsub() + @callback + def async_update_last_seen(self, last_seen): + """Set last seen on the zigpy device.""" + self._zigpy_device.last_seen = last_seen + @callback def async_get_clusters(self): """Get all clusters for this device.""" @@ -242,6 +278,18 @@ class ZHADevice: if ep_id != 0 } + @callback + def async_get_zha_clusters(self): + """Get zigbee home automation clusters for this device.""" + from zigpy.profiles.zha import PROFILE_ID + return { + ep_id: { + IN: endpoint.in_clusters, + OUT: endpoint.out_clusters + } for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 and endpoint.profile_id == PROFILE_ID + } + @callback def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=IN): """Get zigbee cluster from this entity.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 563543fa4bd..8a925ddfda4 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,17 +18,17 @@ from .const import ( ZHA_DISCOVERY_NEW, DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, COMPONENT_CLUSTERS, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, - GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, - OPENING, ZONE, OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, + GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, OPENING, ZONE, + OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, NO_SENSOR_CLUSTERS, - POWER_CONFIGURATION_CHANNEL) + REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, + NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS, + DATA_ZHA_GATEWAY, ACCELERATION) from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( - AttributeListeningChannel, EventRelayChannel, ZDOChannel + AttributeListeningChannel, EventRelayChannel, ZDOChannel, MAINS_POWERED ) -from .channels.general import BasicChannel from .channels.registry import ZIGBEE_CHANNEL_REGISTRY from .helpers import convert_ieee @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = {} BINARY_SENSOR_TYPES = {} SMARTTHINGS_HUMIDITY_CLUSTER = 64581 +SMARTTHINGS_ACCELERATION_CLUSTER = 64514 EntityReference = collections.namedtuple( 'EntityReference', 'reference_id zha_device cluster_channels device_info') @@ -44,14 +45,16 @@ EntityReference = collections.namedtuple( class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - def __init__(self, hass, config): + def __init__(self, hass, config, zha_storage): """Initialize the gateway.""" self._hass = hass self._config = config self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._devices = {} self._device_registry = collections.defaultdict(list) + self.zha_storage = zha_storage hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component + hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self def device_joined(self, device): """Handle device joined. @@ -123,12 +126,16 @@ class ZHAGateway: ) @callback - def _async_get_or_create_device(self, zigpy_device): + def _async_get_or_create_device(self, zigpy_device, is_new_join): """Get or create a ZHA device.""" zha_device = self._devices.get(zigpy_device.ieee) if zha_device is None: zha_device = ZHADevice(self._hass, zigpy_device, self) self._devices[zigpy_device.ieee] = zha_device + if not is_new_join: + entry = self.zha_storage.async_get_or_create(zha_device) + zha_device.async_update_last_seen(entry.last_seen) + zha_device.set_power_source(entry.power_source) return zha_device @callback @@ -147,9 +154,16 @@ class ZHAGateway: if device.status is DeviceStatus.INITIALIZED: device.update_available(True) + async def async_update_device_storage(self): + """Update the devices in the store.""" + for device in self.devices.values(): + self.zha_storage.async_update(device) + await self.zha_storage.async_save() + async def async_device_initialized(self, device, is_new_join): """Handle device joined and basic information discovered (async).""" - zha_device = self._async_get_or_create_device(device) + zha_device = self._async_get_or_create_device(device, is_new_join) + discovery_infos = [] for endpoint_id, endpoint in device.endpoints.items(): self._async_process_endpoint( @@ -160,16 +174,16 @@ class ZHAGateway: if is_new_join: # configure the device await zha_device.async_configure() - elif not zha_device.available and zha_device.power_source is not None\ - and zha_device.power_source != BasicChannel.BATTERY\ - and zha_device.power_source != BasicChannel.UNKNOWN: - # the device is currently marked unavailable and it isn't a battery - # powered device so we should be able to update it now + zha_device.update_available(True) + elif zha_device.power_source is not None\ + and zha_device.power_source == MAINS_POWERED: + # the device isn't a battery powered device so we should be able + # to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, "with power source: {}".format( - BasicChannel.POWER_SOURCES.get(zha_device.power_source) + ZDOChannel.POWER_SOURCES.get(zha_device.power_source) ) ) await zha_device.async_initialize(from_cache=False) @@ -186,11 +200,6 @@ class ZHAGateway: device_entity = _async_create_device_entity(zha_device) await self._component.async_add_entities([device_entity]) - if is_new_join: - # because it's a new join we can immediately mark the device as - # available. We do it here because the entities didn't exist above - zha_device.update_available(True) - @callback def _async_process_endpoint( self, endpoint_id, endpoint, discovery_infos, device, zha_device, @@ -450,6 +459,11 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) + NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + + BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', @@ -494,6 +508,7 @@ def establish_device_mappings(): zcl.clusters.security.IasZone: 'binary_sensor', zcl.clusters.measurement.OccupancySensing: 'binary_sensor', zcl.clusters.hvac.Fan: 'fan', + SMARTTHINGS_ACCELERATION_CLUSTER: 'binary_sensor', }) SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ @@ -516,7 +531,8 @@ def establish_device_mappings(): BINARY_SENSOR_TYPES.update({ zcl.clusters.measurement.OccupancySensing.cluster_id: OCCUPANCY, zcl.clusters.security.IasZone.cluster_id: ZONE, - zcl.clusters.general.OnOff.cluster_id: OPENING + zcl.clusters.general.OnOff.cluster_id: OPENING, + SMARTTHINGS_ACCELERATION_CLUSTER: ACCELERATION, }) CLUSTER_REPORT_CONFIGS.update({ @@ -533,6 +549,7 @@ def establish_device_mappings(): zcl.clusters.general.PollControl.cluster_id: [], zcl.clusters.general.GreenPowerProxy.cluster_id: [], zcl.clusters.general.OnOffConfiguration.cluster_id: [], + zcl.clusters.lightlink.LightLink.cluster_id: [], zcl.clusters.general.OnOff.cluster_id: [{ 'attr': 'on_off', 'config': REPORT_CONFIG_IMMEDIATE @@ -567,6 +584,27 @@ def establish_device_mappings(): 50 ) }], + SMARTTHINGS_ACCELERATION_CLUSTER: [{ + 'attr': 'acceleration', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'x_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'y_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'z_axis', + 'config': REPORT_CONFIG_ASAP + }], + SMARTTHINGS_HUMIDITY_CLUSTER: [{ + 'attr': 'measured_value', + 'config': ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + 50 + ) + }], zcl.clusters.measurement.PressureMeasurement.cluster_id: [{ 'attr': 'measured_value', 'config': REPORT_CONFIG_DEFAULT diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 643e44ada1b..d6e9cc32338 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -5,14 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import asyncio +import collections import logging from concurrent.futures import TimeoutError as Timeout +from homeassistant.core import callback from .const import ( DEFAULT_BAUDRATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_RPT_CHANGE, RadioType) + REPORT_CONFIG_RPT_CHANGE, RadioType, IN, OUT, BINDABLE_CLUSTERS) _LOGGER = logging.getLogger(__name__) +ClusterPair = collections.namedtuple( + 'ClusterPair', 'source_cluster target_cluster') + async def safe_read(cluster, attributes, allow_cache=True, only_cache=False, manufacturer=None): @@ -157,3 +162,44 @@ def get_attr_id_by_name(cluster, attr_name): """Get the attribute id for a cluster attribute by its name.""" return next((attrid for attrid, (attrname, datatype) in cluster.attributes.items() if attr_name == attrname), None) + + +async def get_matched_clusters(source_zha_device, target_zha_device): + """Get matched input/output cluster pairs for 2 devices.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + clusters_to_bind = [] + + for endpoint_id in source_clusters: + for cluster_id in source_clusters[endpoint_id][OUT]: + if cluster_id not in BINDABLE_CLUSTERS: + continue + for t_endpoint_id in target_clusters: + if cluster_id in target_clusters[t_endpoint_id][IN]: + cluster_pair = ClusterPair( + source_cluster=source_clusters[ + endpoint_id][OUT][cluster_id], + target_cluster=target_clusters[ + t_endpoint_id][IN][cluster_id] + ) + clusters_to_bind.append(cluster_pair) + return clusters_to_bind + + +@callback +def async_is_bindable_target(source_zha_device, target_zha_device): + """Determine if target is bindable to source.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + + bindables = set(BINDABLE_CLUSTERS) + for endpoint_id in source_clusters: + for t_endpoint_id in target_clusters: + matches = set( + source_clusters[endpoint_id][OUT].keys() + ).intersection( + target_clusters[t_endpoint_id][IN].keys() + ) + if any(bindable in bindables for bindable in matches): + return True + return False diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py new file mode 100644 index 00000000000..f3547cea8a4 --- /dev/null +++ b/homeassistant/components/zha/core/store.py @@ -0,0 +1,160 @@ +"""Data storage helper for ZHA.""" +import logging +from collections import OrderedDict +# pylint: disable=W0611 +from typing import MutableMapping # noqa: F401 +from typing import cast + +import attr + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +DATA_REGISTRY = 'zha_storage' + +STORAGE_KEY = 'zha.storage' +STORAGE_VERSION = 1 +SAVE_DELAY = 10 + + +@attr.s(slots=True, frozen=True) +class ZhaDeviceEntry: + """Zha Device storage Entry.""" + + name = attr.ib(type=str, default=None) + ieee = attr.ib(type=str, default=None) + power_source = attr.ib(type=int, default=None) + manufacturer_code = attr.ib(type=int, default=None) + last_seen = attr.ib(type=float, default=None) + + +class ZhaDeviceStorage: + """Class to hold a registry of zha devices.""" + + def __init__(self, hass: HomeAssistantType) -> None: + """Initialize the zha device storage.""" + self.hass = hass + self.devices = {} # type: MutableMapping[str, ZhaDeviceEntry] + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + @callback + def async_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + device_entry = ZhaDeviceEntry( + name=device.name, + ieee=str(device.ieee), + power_source=device.power_source, + manufacturer_code=device.manufacturer_code, + last_seen=device.last_seen + + ) + self.devices[device_entry.ieee] = device_entry + + return self.async_update(device) + + @callback + def async_get_or_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + if ieee_str in self.devices: + return self.devices[ieee_str] + return self.async_create(device) + + @callback + def async_create_or_update(self, device) -> ZhaDeviceEntry: + """Create or update a ZhaDeviceEntry.""" + if str(device.ieee) in self.devices: + return self.async_update(device) + return self.async_create(device) + + @callback + def async_delete(self, device) -> None: + """Delete ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + if ieee_str in self.devices: + del self.devices[ieee_str] + self.async_schedule_save() + + @callback + def async_update(self, device) -> ZhaDeviceEntry: + """Update name of ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + old = self.devices[ieee_str] + + changes = {} + + if device.power_source != old.power_source: + changes['power_source'] = device.power_source + + if device.manufacturer_code != old.manufacturer_code: + changes['manufacturer_code'] = device.manufacturer_code + + changes['last_seen'] = device.last_seen + + new = self.devices[ieee_str] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + + async def async_load(self) -> None: + """Load the registry of zha device entries.""" + data = await self._store.async_load() + + devices = OrderedDict() # type: OrderedDict[str, ZhaDeviceEntry] + + if data is not None: + for device in data['devices']: + devices[device['ieee']] = ZhaDeviceEntry( + name=device['name'], + ieee=device['ieee'], + power_source=device['power_source'], + manufacturer_code=device['manufacturer_code'], + last_seen=device['last_seen'] if 'last_seen' in device + else None + ) + + self.devices = devices + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the registry of zha devices.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + async def async_save(self) -> None: + """Save the registry of zha devices.""" + await self._store.async_save(self._data_to_save()) + + @callback + def _data_to_save(self) -> dict: + """Return data for the registry of zha devices to store in a file.""" + data = {} + + data['devices'] = [ + { + 'name': entry.name, + 'ieee': entry.ieee, + 'power_source': entry.power_source, + 'manufacturer_code': entry.manufacturer_code, + 'last_seen': entry.last_seen + } for entry in self.devices.values() + ] + + return data + + +@bind_hass +async def async_get_registry(hass: HomeAssistantType) -> ZhaDeviceStorage: + """Return zha device storage instance.""" + task = hass.data.get(DATA_REGISTRY) + + if task is None: + async def _load_reg() -> ZhaDeviceStorage: + registry = ZhaDeviceStorage(hass) + await registry.async_load() + return registry + + task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + + return cast(ZhaDeviceStorage, await task) diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 5632c849d59..7563481bbb7 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -98,6 +98,7 @@ class ZhaDeviceEntity(ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() + await self.async_check_recently_seen() if self._battery_channel: await self.async_accept_signal( self._battery_channel, SIGNAL_STATE_ATTR, @@ -147,4 +148,7 @@ class ZhaDeviceEntity(ZhaEntity): battery = await self._battery_channel.get_attribute_value( 'battery_percentage_remaining') if battery is not None: + # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ + battery = battery / 2 + battery = int(round(battery)) self._device_state_attributes['battery_level'] = battery diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 2f5aed4ca29..d0848222549 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -6,23 +6,28 @@ https://home-assistant.io/components/zha/ """ import logging +import time +from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import slugify from .core.const import ( DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME, SIGNAL_REMOVE ) +from .core.channels import MAINS_POWERED _LOGGER = logging.getLogger(__name__) ENTITY_SUFFIX = 'entity_suffix' +RESTART_GRACE_PERIOD = 7200 # 2 hours -class ZhaEntity(entity.Entity): +class ZhaEntity(RestoreEntity, entity.Entity): """A base class for ZHA entities.""" _domain = None # Must be overridden by subclasses @@ -136,6 +141,7 @@ class ZhaEntity(entity.Entity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() + await self.async_check_recently_seen() await self.async_accept_signal( None, "{}_{}".format(self.zha_device.available_signal, 'entity'), self.async_set_available, @@ -149,11 +155,28 @@ class ZhaEntity(entity.Entity): self._zha_device.ieee, self.entity_id, self._zha_device, self.cluster_channels, self.device_info) + async def async_check_recently_seen(self): + """Check if the device was seen within the last 2 hours.""" + last_state = await self.async_get_last_state() + if last_state and self._zha_device.last_seen and ( + time.time() - self._zha_device.last_seen < + RESTART_GRACE_PERIOD): + self.async_set_available(True) + if self.zha_device.power_source != MAINS_POWERED: + # mains powered devices will get real time state + self.async_restore_last_state(last_state) + self._zha_device.set_available(True) + async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" for unsub in self._unsubs: unsub() + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + pass + async def async_update(self): """Retrieve latest state.""" for channel in self.cluster_channels: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 761dfaede1e..73989ef32b4 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -6,6 +6,7 @@ at https://home-assistant.io/components/fan.zha/ """ import logging +from homeassistant.core import callback from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) @@ -92,6 +93,11 @@ class ZhaFan(ZhaEntity, FanEntity): await self.async_accept_signal( self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = VALUE_TO_SPEED.get(last_state.state, last_state.state) + @property def supported_features(self) -> int: """Flag supported features.""" @@ -139,3 +145,11 @@ class ZhaFan(ZhaEntity, FanEntity): """Set the speed of the fan.""" await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) self.async_set_state(speed) + + async def async_update(self): + """Attempt to retrieve on off state from the fan.""" + await super().async_update() + if self._fan_channel: + state = await self._fan_channel.get_attribute_value('fan_mode') + if state is not None: + self._state = VALUE_TO_SPEED.get(state, self._state) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 740d67db1bd..8b2cd349b9d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -4,9 +4,12 @@ Lights on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/light.zha/ """ +from datetime import timedelta import logging from homeassistant.components import light +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import ( @@ -26,6 +29,7 @@ CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 +SCAN_INTERVAL = timedelta(minutes=60) async def async_setup_platform(hass, config, async_add_entities, @@ -92,6 +96,11 @@ class Light(ZhaEntity, light.Light): self._supported_features |= light.SUPPORT_COLOR self._hs_color = (0, 0) + @property + def should_poll(self) -> bool: + """Poll state from device.""" + return True + @property def is_on(self) -> bool: """Return true if entity is on.""" @@ -149,6 +158,17 @@ class Light(ZhaEntity, light.Light): await self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state == STATE_ON + if 'brightness' in last_state.attributes: + self._brightness = last_state.attributes['brightness'] + if 'color_temp' in last_state.attributes: + self._color_temp = last_state.attributes['color_temp'] + if 'hs_color' in last_state.attributes: + self._hs_color = last_state.attributes['hs_color'] + async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) @@ -217,3 +237,13 @@ class Light(ZhaEntity, light.Light): return self._state = False self.async_schedule_update_ha_state() + + async def async_update(self): + """Attempt to retrieve on off state from the light.""" + await super().async_update() + if self._on_off_channel: + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') + if self._level_channel: + self._brightness = await self._level_channel.get_attribute_value( + 'current_level') diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 6dcdbb845dc..56ce97c87a0 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -6,8 +6,11 @@ at https://home-assistant.io/components/sensor.zha/ """ import logging +from homeassistant.core import callback from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + TEMP_CELSIUS, POWER_WATT, ATTR_UNIT_OF_MEASUREMENT +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, @@ -69,8 +72,8 @@ UNIT_REGISTRY = { TEMPERATURE: TEMP_CELSIUS, PRESSURE: 'hPa', ILLUMINANCE: 'lx', - METERING: 'W', - ELECTRICAL_MEASUREMENT: 'W', + METERING: POWER_WATT, + ELECTRICAL_MEASUREMENT: POWER_WATT, GENERIC: None } @@ -133,22 +136,22 @@ class Sensor(ZhaEntity): def __init__(self, unique_id, zha_device, channels, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, channels, **kwargs) - sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) - self._unit = UNIT_REGISTRY.get(sensor_type) + self._sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) + self._unit = UNIT_REGISTRY.get(self._sensor_type) self._formatter_function = FORMATTER_FUNC_REGISTRY.get( - sensor_type, + self._sensor_type, pass_through_formatter ) self._force_update = FORCE_UPDATE_REGISTRY.get( - sensor_type, + self._sensor_type, False ) self._should_poll = POLLING_REGISTRY.get( - sensor_type, + self._sensor_type, False ) self._channel = self.cluster_channels.get( - CHANNEL_REGISTRY.get(sensor_type, ATTRIBUTE_CHANNEL) + CHANNEL_REGISTRY.get(self._sensor_type, ATTRIBUTE_CHANNEL) ) async def async_added_to_hass(self): @@ -176,5 +179,15 @@ class Sensor(ZhaEntity): def async_set_state(self, state): """Handle state update from channel.""" + # this is necessary because HA saves the unit based on what shows in + # the UI and not based on what the sensor has configured so we need + # to flip it back after state restoration + self._unit = UNIT_REGISTRY.get(self._sensor_type) self._state = self._formatter_function(state) self.async_schedule_update_ha_state() + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state + self._unit = last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index bdbdd7a6a76..f1bf671a43d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -7,6 +7,8 @@ at https://home-assistant.io/components/switch.zha/ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, @@ -71,11 +73,19 @@ class Switch(ZhaEntity, SwitchDevice): async def async_turn_on(self, **kwargs): """Turn the entity on.""" - await self._on_off_channel.on() + success = await self._on_off_channel.on() + if not success: + return + self._state = True + self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - await self._on_off_channel.off() + success = await self._on_off_channel.off() + if not success: + return + self._state = False + self.async_schedule_update_ha_state() def async_set_state(self, state): """Handle state update from channel.""" @@ -92,3 +102,15 @@ class Switch(ZhaEntity, SwitchDevice): await super().async_added_to_hass() await self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state == STATE_ON + + async def async_update(self): + """Attempt to retrieve on off state from the switch.""" + await super().async_update() + if self._on_off_channel: + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') diff --git a/homeassistant/components/zwave/binary_sensor.py b/homeassistant/components/zwave/binary_sensor.py index 478bfcbda7b..bc2b487171d 100644 --- a/homeassistant/components/zwave/binary_sensor.py +++ b/homeassistant/components/zwave/binary_sensor.py @@ -5,11 +5,14 @@ import homeassistant.util.dt as dt_util from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import track_point_in_time -from homeassistant.components import zwave -from homeassistant.components.zwave import workaround from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) +from . import ( + workaround, + ZWaveDeviceEntity +) +from .const import COMMAND_CLASS_SENSOR_BINARY _LOGGER = logging.getLogger(__name__) @@ -40,17 +43,17 @@ def get_device(values, **kwargs): if workaround.get_device_component_mapping(values.primary) == DOMAIN: return ZWaveBinarySensor(values, None) - if values.primary.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY: + if values.primary.command_class == COMMAND_CLASS_SENSOR_BINARY: return ZWaveBinarySensor(values, None) return None -class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): +class ZWaveBinarySensor(BinarySensorDevice, ZWaveDeviceEntity): """Representation of a binary sensor within Z-Wave.""" def __init__(self, values, device_class): """Initialize the sensor.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._sensor_type = device_class self._state = self.values.primary.data diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index b0ab273e86a..0c57b94739a 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -7,10 +7,10 @@ from homeassistant.components.climate.const import ( DOMAIN, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) -from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index e40a885ede1..a3cd7269b99 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -3,11 +3,13 @@ import logging from homeassistant.core import callback from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) -from homeassistant.components import zwave -from homeassistant.components.zwave import ( - ZWaveDeviceEntity, workaround) from homeassistant.components.cover import CoverDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, workaround) +from .const import ( + COMMAND_CLASS_SWITCH_MULTILEVEL, COMMAND_CLASS_SWITCH_BINARY, + COMMAND_CLASS_BARRIER_OPERATOR, DATA_NETWORK) _LOGGER = logging.getLogger(__name__) @@ -32,26 +34,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(hass, values, node_config, **kwargs): """Create Z-Wave entity device.""" - invert_buttons = node_config.get(zwave.CONF_INVERT_OPENCLOSE_BUTTONS) + invert_buttons = node_config.get(CONF_INVERT_OPENCLOSE_BUTTONS) if (values.primary.command_class == - zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL + COMMAND_CLASS_SWITCH_MULTILEVEL and values.primary.index == 0): return ZwaveRollershutter(hass, values, invert_buttons) - if values.primary.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY: + if values.primary.command_class == COMMAND_CLASS_SWITCH_BINARY: return ZwaveGarageDoorSwitch(values) if values.primary.command_class == \ - zwave.const.COMMAND_CLASS_BARRIER_OPERATOR: + COMMAND_CLASS_BARRIER_OPERATOR: return ZwaveGarageDoorBarrier(values) return None -class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): +class ZwaveRollershutter(ZWaveDeviceEntity, CoverDevice): """Representation of an Z-Wave cover.""" def __init__(self, hass, values, invert_buttons): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._network = hass.data[zwave.const.DATA_NETWORK] + self._network = hass.data[DATA_NETWORK] self._open_id = None self._close_id = None self._current_position = None @@ -115,7 +117,7 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): self._network.manager.releaseButton(self._open_id) -class ZwaveGarageDoorBase(zwave.ZWaveDeviceEntity, CoverDevice): +class ZwaveGarageDoorBase(ZWaveDeviceEntity, CoverDevice): """Base class for a Zwave garage door device.""" def __init__(self, values): diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index b2731f7d9a7..193f4fa59b7 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -6,8 +6,8 @@ from homeassistant.core import callback from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -52,12 +52,12 @@ def get_device(values, **kwargs): return ZwaveFan(values) -class ZwaveFan(zwave.ZWaveDeviceEntity, FanEntity): +class ZwaveFan(ZWaveDeviceEntity, FanEntity): """Representation of a Z-Wave fan.""" def __init__(self, values): """Initialize the Z-Wave fan device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.update_properties() def update_properties(self): diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 0af85b84177..15bd5968ad3 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -7,10 +7,15 @@ from homeassistant.components.light import ( ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, DOMAIN, Light) -from homeassistant.components import zwave from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util +from . import ( + CONF_REFRESH_VALUE, + CONF_REFRESH_DELAY, + const, + ZWaveDeviceEntity, +) _LOGGER = logging.getLogger(__name__) @@ -68,13 +73,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(node, values, node_config, **kwargs): """Create Z-Wave entity device.""" - refresh = node_config.get(zwave.CONF_REFRESH_VALUE) - delay = node_config.get(zwave.CONF_REFRESH_DELAY) + refresh = node_config.get(CONF_REFRESH_VALUE) + delay = node_config.get(CONF_REFRESH_DELAY) _LOGGER.debug("node=%d value=%d node_config=%s CONF_REFRESH_VALUE=%s" " CONF_REFRESH_DELAY=%s", node.node_id, values.primary.value_id, node_config, refresh, delay) - if node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_COLOR): + if node.has_command_class(const.COMMAND_CLASS_SWITCH_COLOR): return ZwaveColorLight(values, refresh, delay) return ZwaveDimmer(values, refresh, delay) @@ -104,12 +109,12 @@ def ct_to_hs(temp): return [int(val) for val in colorlist] -class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): +class ZwaveDimmer(ZWaveDeviceEntity, Light): """Representation of a Z-Wave dimmer.""" def __init__(self, values, refresh, delay): """Initialize the light.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._brightness = None self._state = None self._supported_features = None diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py old mode 100644 new mode 100755 index 7c0958e596a..34b5de18c8f --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -5,9 +5,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.lock import DOMAIN, LockDevice -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.config_validation as cv +from . import ZWaveDeviceEntity, const _LOGGER = logging.getLogger(__name__) @@ -34,14 +34,27 @@ DEVICE_MAPPINGS = { # Kwikset 914TRL ZW500 (0x0090, 0x440): WORKAROUND_DEVICE_STATE, (0x0090, 0x446): WORKAROUND_DEVICE_STATE, - # Yale YRD210, Yale YRD240 - (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, - # Yale YRL220/YRD220 + # Yale Locks + # Yale YRD210, YRD220, YRL220 (0x0129, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - # Yale YRD120 + # Yale YRD210, YRD220 + (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRL210, YRL220 + (0x0129, 0x0409): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD256 + (0x0129, 0x0600): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD110, YRD120 (0x0129, 0x0800): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - # Yale YRD220 (as reported by adrum in PR #17386) + # Yale YRD446 + (0x0129, 0x1000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRL220 + (0x0129, 0x2132): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + (0x0129, 0x3CAC): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD210, YRD220 + (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD220 + (0x0129, 0xFFFF): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD220 (Older Yale products with incorrect vendor ID) (0x0109, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, # Schlage BE469 (0x003B, 0x5044): WORKAROUND_DEVICE_STATE | WORKAROUND_TRACK_MESSAGE, @@ -122,18 +135,18 @@ ALARM_TYPE_STD = [ ] SET_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), vol.Required(ATTR_USERCODE): cv.string, }) GET_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), }) CLEAR_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), }) @@ -153,17 +166,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, 'zwave_new_lock', async_add_lock) - network = hass.data[zwave.const.DATA_NETWORK] + network = hass.data[const.DATA_NETWORK] def set_usercode(service): """Set the usercode to index X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) usercode = service.data.get(ATTR_USERCODE) for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue if len(str(usercode)) < 4: @@ -177,12 +190,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_usercode(service): """Get a usercode at index X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue _LOGGER.info("Usercode at slot %s is: %s", value.index, value.data) @@ -190,13 +203,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def clear_usercode(service): """Set usercode to slot X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) data = '' for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue for i in range(len(value.data)): @@ -223,12 +236,12 @@ def get_device(node, values, **kwargs): return ZwaveLock(values) -class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): +class ZwaveLock(ZWaveDeviceEntity, LockDevice): """Representation of a Z-Wave Lock.""" def __init__(self, values): """Initialize the Z-Wave lock device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._state = None self._notification = None self._lock_status = None @@ -284,12 +297,12 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if self._track_message_workaround: this_message = self.node.stats['lastReceivedMessage'][5] - if this_message == zwave.const.COMMAND_CLASS_DOOR_LOCK: + if this_message == const.COMMAND_CLASS_DOOR_LOCK: self._state = self.values.primary.data _LOGGER.debug("set state to %s based on message tracking", self._state) if self._previous_message == \ - zwave.const.COMMAND_CLASS_DOOR_LOCK: + const.COMMAND_CLASS_DOOR_LOCK: if self._state: self._notification = \ LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK] diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index 44fc132cf77..e1c1914dccc 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -2,10 +2,12 @@ import logging from homeassistant.core import callback from homeassistant.components.sensor import DOMAIN -from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.helpers.dispatcher import async_dispatcher_connect - +from . import ( + const, + ZWaveDeviceEntity, +) _LOGGER = logging.getLogger(__name__) @@ -28,23 +30,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(node, values, **kwargs): """Create Z-Wave entity device.""" # Generic Device mappings - if node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL): + if node.has_command_class(const.COMMAND_CLASS_SENSOR_MULTILEVEL): return ZWaveMultilevelSensor(values) - if node.has_command_class(zwave.const.COMMAND_CLASS_METER) and \ - values.primary.type == zwave.const.TYPE_DECIMAL: + if node.has_command_class(const.COMMAND_CLASS_METER) and \ + values.primary.type == const.TYPE_DECIMAL: return ZWaveMultilevelSensor(values) - if node.has_command_class(zwave.const.COMMAND_CLASS_ALARM) or \ - node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_ALARM): + if node.has_command_class(const.COMMAND_CLASS_ALARM) or \ + node.has_command_class(const.COMMAND_CLASS_SENSOR_ALARM): return ZWaveAlarmSensor(values) return None -class ZWaveSensor(zwave.ZWaveDeviceEntity): +class ZWaveSensor(ZWaveDeviceEntity): """Representation of a Z-Wave sensor.""" def __init__(self, values): """Initialize the sensor.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.update_properties() def update_properties(self): diff --git a/homeassistant/components/zwave/switch.py b/homeassistant/components/zwave/switch.py index ef544222546..f9506aea798 100644 --- a/homeassistant/components/zwave/switch.py +++ b/homeassistant/components/zwave/switch.py @@ -3,8 +3,11 @@ import logging import time from homeassistant.core import callback from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + ZWaveDeviceEntity, + workaround, +) _LOGGER = logging.getLogger(__name__) @@ -30,15 +33,15 @@ def get_device(values, **kwargs): return ZwaveSwitch(values) -class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): +class ZwaveSwitch(ZWaveDeviceEntity, SwitchDevice): """Representation of a Z-Wave switch.""" def __init__(self, values): """Initialize the Z-Wave switch device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.refresh_on_update = ( - zwave.workaround.get_device_mapping(values.primary) == - zwave.workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) + workaround.get_device_mapping(values.primary) == + workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) self.last_update = time.perf_counter() self._state = self.values.primary.data diff --git a/homeassistant/config.py b/homeassistant/config.py index 492db240eee..19b8087e538 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -75,6 +75,9 @@ introduction: # http: # base_url: example.duckdns.org:8123 +# Discover some devices automatically +discovery: + # Sensors sensor: # Weather prediction @@ -428,7 +431,7 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: async def async_process_ha_core_config( hass: HomeAssistant, config: Dict, - has_api_password: bool = False, + api_password: Optional[str] = None, trusted_networks: Optional[Any] = None) -> None: """Process the [homeassistant] section from the configuration. @@ -444,8 +447,11 @@ async def async_process_ha_core_config( auth_conf = [ {'type': 'homeassistant'} ] - if has_api_password: - auth_conf.append({'type': 'legacy_api_password'}) + if api_password: + auth_conf.append({ + 'type': 'legacy_api_password', + 'api_password': api_password, + }) if trusted_networks: auth_conf.append({ 'type': 'trusted_networks', diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b22c2e197c..e00d7204a79 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -119,6 +119,7 @@ should follow the same return values as a normal step. If the result of the step is to show a form, the user will be able to continue the flow from the config panel. """ +import asyncio import logging import functools import uuid @@ -160,6 +161,7 @@ FLOWS = [ 'locative', 'luftdaten', 'mailgun', + 'mobile_app', 'mqtt', 'nest', 'openuv', @@ -205,6 +207,11 @@ ENTRY_STATE_NOT_LOADED = 'not_loaded' # An error occurred when trying to unload the entry ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' +UNRECOVERABLE_STATES = ( + ENTRY_STATE_MIGRATION_ERROR, + ENTRY_STATE_FAILED_UNLOAD, +) + DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' DISCOVERY_SOURCES = ( SOURCE_DISCOVERY, @@ -221,6 +228,18 @@ CONN_CLASS_ASSUMED = 'assumed' CONN_CLASS_UNKNOWN = 'unknown' +class ConfigError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownEntry(ConfigError): + """Unknown entry specified.""" + + +class OperationNotAllowed(ConfigError): + """Raised when a config entry operation is not allowed.""" + + class ConfigEntry: """Hold a configuration entry.""" @@ -228,7 +247,7 @@ class ConfigEntry: 'source', 'connection_class', 'state', '_setup_lock', 'update_listeners', '_async_cancel_retry_setup') - def __init__(self, version: str, domain: str, title: str, data: dict, + def __init__(self, version: int, domain: str, title: str, data: dict, source: str, connection_class: str, options: Optional[dict] = None, entry_id: Optional[str] = None, @@ -283,7 +302,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error('%s.async_config_entry did not return boolean', + _LOGGER.error('%s.async_setup_entry did not return boolean', component.DOMAIN) result = False except ConfigEntryNotReady: @@ -316,7 +335,7 @@ class ConfigEntry: else: self.state = ENTRY_STATE_SETUP_ERROR - async def async_unload(self, hass, *, component=None): + async def async_unload(self, hass, *, component=None) -> bool: """Unload an entry. Returns if unload is possible and was successful. @@ -325,17 +344,22 @@ class ConfigEntry: component = getattr(hass.components, self.domain) if component.DOMAIN == self.domain: - if self._async_cancel_retry_setup is not None: - self._async_cancel_retry_setup() - self.state = ENTRY_STATE_NOT_LOADED - return True + if self.state in UNRECOVERABLE_STATES: + return False if self.state != ENTRY_STATE_LOADED: + if self._async_cancel_retry_setup is not None: + self._async_cancel_retry_setup() + self._async_cancel_retry_setup = None + + self.state = ENTRY_STATE_NOT_LOADED return True supports_unload = hasattr(component, 'async_unload_entry') if not supports_unload: + if component.DOMAIN == self.domain: + self.state = ENTRY_STATE_FAILED_UNLOAD return False try: @@ -355,6 +379,17 @@ class ConfigEntry: self.state = ENTRY_STATE_FAILED_UNLOAD return False + async def async_remove(self, hass: HomeAssistant) -> None: + """Invoke remove callback on component.""" + component = getattr(hass.components, self.domain) + if not hasattr(component, 'async_remove_entry'): + return + try: + await component.async_remove_entry(hass, self) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error calling entry remove callback %s for %s', + self.title, component.DOMAIN) + async def async_migrate(self, hass: HomeAssistant) -> bool: """Migrate an entry. @@ -420,14 +455,6 @@ class ConfigEntry: } -class ConfigError(HomeAssistantError): - """Error while configuring an account.""" - - -class UnknownEntry(ConfigError): - """Unknown entry specified.""" - - class ConfigEntries: """Manage the configuration entries. @@ -474,34 +501,35 @@ class ConfigEntries: async def async_remove(self, entry_id): """Remove an entry.""" - found = None - for index, entry in enumerate(self._entries): - if entry.entry_id == entry_id: - found = index - break + entry = self.async_get_entry(entry_id) - if found is None: + if entry is None: raise UnknownEntry - entry = self._entries.pop(found) + if entry.state in UNRECOVERABLE_STATES: + unload_success = entry.state != ENTRY_STATE_FAILED_UNLOAD + else: + unload_success = await self.async_unload(entry_id) + + await entry.async_remove(self.hass) + + self._entries.remove(entry) self._async_schedule_save() - unloaded = await entry.async_unload(self.hass) + dev_reg, ent_reg = await asyncio.gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + ) - device_registry = await \ - self.hass.helpers.device_registry.async_get_registry() - device_registry.async_clear_config_entry(entry_id) - - entity_registry = await \ - self.hass.helpers.entity_registry.async_get_registry() - entity_registry.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id) + ent_reg.async_clear_config_entry(entry_id) return { - 'require_restart': not unloaded + 'require_restart': not unload_success } - async def async_load(self) -> None: - """Handle loading the config.""" + async def async_initialize(self) -> None: + """Initialize config entry config.""" # Migrating for config entries stored before 0.73 config = await self.hass.helpers.storage.async_migrator( self.hass.config.path(PATH_CONFIG), self._store, @@ -527,6 +555,56 @@ class ConfigEntries: options=entry.get('options')) for entry in config['entries']] + async def async_setup(self, entry_id: str) -> bool: + """Set up a config entry. + + Return True if entry has been successfully loaded. + """ + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + + if entry.state != ENTRY_STATE_NOT_LOADED: + raise OperationNotAllowed + + # Setup Component if not set up yet + if entry.domain in self.hass.config.components: + await entry.async_setup(self.hass) + else: + # Setting up the component will set up all its config entries + result = await async_setup_component( + self.hass, entry.domain, self._hass_config) + + if not result: + return result + + return entry.state == ENTRY_STATE_LOADED + + async def async_unload(self, entry_id: str) -> bool: + """Unload a config entry.""" + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + + if entry.state in UNRECOVERABLE_STATES: + raise OperationNotAllowed + + return await entry.async_unload(self.hass) + + async def async_reload(self, entry_id: str) -> bool: + """Reload an entry. + + If an entry was not loaded, will just load. + """ + unload_result = await self.async_unload(entry_id) + + if not unload_result: + return unload_result + + return await self.async_setup(entry_id) + @callback def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF): """Update a config entry.""" @@ -597,14 +675,7 @@ class ConfigEntries: self._entries.append(entry) self._async_schedule_save() - # Setup entry - if entry.domain in self.hass.config.components: - # Component already set up, just need to call setup_entry - await entry.async_setup(self.hass) - else: - # Setting up component will also load the entries - await async_setup_component( - self.hass, entry.domain, self._hass_config) + await self.async_setup(entry.entry_id) result['result'] = entry return result diff --git a/homeassistant/const.py b/homeassistant/const.py index a740bdd441e..df0146cde62 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 = 89 -PATCH_VERSION = '2' +MINOR_VERSION = 90 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -245,6 +245,9 @@ ATTR_NAME = 'name' # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' +# Contains one string or a list of strings, each being an area id +ATTR_AREA_ID = 'area_id' + # String with a friendly name for the entity ATTR_FRIENDLY_NAME = 'friendly_name' @@ -319,6 +322,13 @@ ATTR_DEVICE_CLASS = 'device_class' ATTR_TEMPERATURE = 'temperature' # #### UNITS OF MEASUREMENT #### +# Power units +POWER_WATT = 'W' + +# Energy units +ENERGY_KILO_WATT_HOUR = 'kWh' +ENERGY_WATT_HOUR = 'Wh' + # Temperature units TEMP_CELSIUS = '°C' TEMP_FAHRENHEIT = '°F' diff --git a/homeassistant/core.py b/homeassistant/core.py index 48ef4f46272..df315ad63c0 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -409,6 +409,10 @@ class Context: type=str, default=None, ) + parent_id = attr.ib( + type=Optional[str], + default=None + ) id = attr.ib( type=str, default=attr.Factory(lambda: uuid.uuid4().hex), @@ -418,6 +422,7 @@ class Context: """Return a dictionary representation of the context.""" return { 'id': self.id, + 'parent_id': self.parent_id, 'user_id': self.user_id, } @@ -1175,7 +1180,7 @@ class Config: # List of loaded components self.components = set() # type: set - # API (HTTP) server configuration + # API (HTTP) server configuration, see components.http.ApiConfig self.api = None # type: Optional[Any] # Directory that holds the configuration diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 57265cf696d..acd0befda4e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -170,11 +170,13 @@ class FlowHandler: } @callback - def async_abort(self, *, reason: str) -> Dict: + def async_abort(self, *, reason: str, + description_placeholders: Optional[Dict] = None) -> Dict: """Abort the config flow.""" return { 'type': RESULT_TYPE_ABORT, 'flow_id': self.flow_id, 'handler': self.handler, - 'reason': reason + 'reason': reason, + 'description_placeholders': description_placeholders, } diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 3fa820f8350..644d14cf869 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -37,6 +37,11 @@ class AreaRegistry: self.areas = {} # type: MutableMapping[str, AreaEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + @callback + def async_get_area(self, area_id: str) -> Optional[AreaEntry]: + """Get all areas.""" + return self.areas.get(area_id) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 21c3b0d0209..1ea6c400208 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,6 +1,7 @@ """Provide a way to connect entities belonging to one device.""" import logging import uuid +from typing import List, Optional from collections import OrderedDict @@ -70,6 +71,11 @@ class DeviceRegistry: self.devices = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + @callback + def async_get(self, device_id: str) -> Optional[DeviceEntry]: + """Get device.""" + return self.devices.get(device_id) + @callback def async_get_device(self, identifiers: set, connections: set): """Check if device is registered.""" @@ -280,3 +286,11 @@ async def async_get_registry(hass) -> DeviceRegistry: task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) return await task + + +@callback +def async_entries_for_area(registry: DeviceRegistry, area_id: str) \ + -> List[DeviceEntry]: + """Return entries that match an area.""" + return [device for device in registry.devices.values() + if device.area_id == area_id] diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index dd9677f6515..4ef5513baf7 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -222,6 +222,23 @@ class Entity: _LOGGER.exception("Update for %s fails", self.entity_id) return + self._async_write_ha_state() + + @callback + def async_write_ha_state(self): + """Write the state to the state machine.""" + if self.hass is None: + raise RuntimeError("Attribute hass is None for {}".format(self)) + + if self.entity_id is None: + raise NoEntitySpecifiedError( + "No entity id specified for entity {}".format(self.name)) + + self._async_write_ha_state() + + @callback + def _async_write_ha_state(self): + """Write the state to the state machine.""" start = timer() if not self.available: @@ -311,13 +328,27 @@ class Entity: def schedule_update_ha_state(self, force_refresh=False): """Schedule an update ha state change task. - That avoid executor dead looks. + Scheduling the update avoids executor deadlocks. + + Entity state and attributes are read when the update ha state change + task is executed. + If state is changed more than once before the ha state change task has + been executed, the intermediate state transitions will be missed. """ self.hass.add_job(self.async_update_ha_state(force_refresh)) @callback def async_schedule_update_ha_state(self, force_refresh=False): - """Schedule an update ha state change task.""" + """Schedule an update ha state change task. + + This method must be run in the event loop. + Scheduling the update avoids executor deadlocks. + + Entity state and attributes are read when the update ha state change + task is executed. + If state is changed more than once before the ha state change task has + been executed, the intermediate state transitions will be missed. + """ self.hass.async_create_task(self.async_update_ha_state(force_refresh)) async def async_device_update(self, warning=True): diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 44213e6d7c8..744cf36ea66 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -12,7 +12,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids from homeassistant.loader import bind_hass from homeassistant.util import slugify from .entity_platform import EntityPlatform @@ -153,8 +153,7 @@ class EntityComponent: await platform.async_reset() return True - @callback - def async_extract_from_service(self, service, expand_group=True): + async def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. Will return all entities if no entities specified in call. @@ -174,7 +173,8 @@ class EntityComponent: return [entity for entity in self.entities if entity.available] - entity_ids = set(extract_entity_ids(self.hass, service, expand_group)) + entity_ids = await async_extract_entity_ids( + self.hass, service, expand_group) return [entity for entity in self.entities if entity.available and entity.entity_id in entity_ids] diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 6ee32f642bc..c0a0dfaa7d9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,7 +10,7 @@ timer. from collections import OrderedDict from itertools import chain import logging -from typing import Optional +from typing import Optional, List import weakref import attr @@ -292,6 +292,14 @@ async def async_get_registry(hass) -> EntityRegistry: return await task +@callback +def async_entries_for_device(registry: EntityRegistry, device_id: str) \ + -> List[RegistryEntry]: + """Return entries that match a device.""" + return [entry for entry in registry.entities.values() + if entry.device_id == device_id] + + async def _async_migrate(entities): """Migrate the YAML config file to storage helper format.""" return { diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 22138d7c2aa..43b8318abc5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,15 +1,18 @@ """Service calling related helpers.""" import asyncio +from functools import wraps import logging from os import path +from typing import Callable import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL +from homeassistant.const import ( + ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ATTR_AREA_ID) import homeassistant.core as ha from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser -from homeassistant.helpers import template +from homeassistant.helpers import template, typing from homeassistant.loader import get_component, bind_hass from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv @@ -89,30 +92,64 @@ async def async_call_from_config(hass, config, blocking=False, variables=None, def extract_entity_ids(hass, service_call, expand_group=True): """Extract a list of entity ids from a service call. + Will convert group entity ids to the entity ids it represents. + """ + return run_coroutine_threadsafe( + async_extract_entity_ids(hass, service_call, expand_group), hass.loop + ).result() + + +@bind_hass +async def async_extract_entity_ids(hass, service_call, expand_group=True): + """Extract a list of entity ids from a service call. + Will convert group entity ids to the entity ids it represents. Async friendly. """ - if not (service_call.data and ATTR_ENTITY_ID in service_call.data): + entity_ids = service_call.data.get(ATTR_ENTITY_ID) + area_ids = service_call.data.get(ATTR_AREA_ID) + + if not entity_ids and not area_ids: return [] - group = hass.components.group + extracted = set() - # Entity ID attr can be a list or a string - service_ent_id = service_call.data[ATTR_ENTITY_ID] + if entity_ids: + # Entity ID attr can be a list or a string + if isinstance(entity_ids, str): + entity_ids = [entity_ids] - if expand_group: + if expand_group: + entity_ids = \ + hass.components.group.expand_entity_ids(entity_ids) - if isinstance(service_ent_id, str): - return group.expand_entity_ids([service_ent_id]) + extracted.update(entity_ids) - return [ent_id for ent_id in - group.expand_entity_ids(service_ent_id)] + if area_ids: + if isinstance(area_ids, str): + area_ids = [area_ids] - if isinstance(service_ent_id, str): - return [service_ent_id] + dev_reg, ent_reg = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + ) + devices = [ + device + for area_id in area_ids + for device in + hass.helpers.device_registry.async_entries_for_area( + dev_reg, area_id) + ] + extracted.update( + entry.entity_id + for device in devices + for entry in + hass.helpers.entity_registry.async_entries_for_device( + ent_reg, device.id) + ) - return service_ent_id + return extracted @bind_hass @@ -213,8 +250,7 @@ async def entity_service_call(hass, platforms, func, call, service_name=''): if not target_all_entities: # A set of entities we're trying to target. - entity_ids = set( - extract_entity_ids(hass, call, True)) + entity_ids = await async_extract_entity_ids(hass, call, True) # If the service function is a string, we'll pass it the service call data if isinstance(func, str): @@ -301,3 +337,25 @@ async def _handle_service_platform_call(func, data, entities, context): assert not pending for future in done: future.result() # pop exception if have + + +@bind_hass +@ha.callback +def async_register_admin_service(hass: typing.HomeAssistantType, domain: str, + service: str, service_func: Callable, + schema: vol.Schema) -> None: + """Register a service that requires admin access.""" + @wraps(service_func) + async def admin_handler(call): + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + if not user.is_admin: + raise Unauthorized(context=call.context) + + await hass.async_add_job(service_func, call) + + hass.services.async_register( + domain, service, admin_handler, schema + ) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 03ae37843d8..d79c68ffd5e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -657,6 +657,7 @@ ENV.filters['sin'] = sine ENV.filters['cos'] = cosine ENV.filters['tan'] = tangent ENV.filters['sqrt'] = square_root +ENV.filters['as_timestamp'] = forgiving_as_timestamp ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index f0df58a51f4..e231d7602cd 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -180,12 +180,15 @@ def _logbook_filtering(hass, last_changed, last_updated): 'new_state': new_state }) - events = [event] * 10**5 + def yield_events(event): + # pylint: disable=protected-access + entities_filter = logbook._generate_filter_from_config({}) + for _ in range(10**5): + if logbook._keep_event(event, entities_filter): + yield event start = timer() - # pylint: disable=protected-access - events = logbook._exclude_events(events, {}) - list(logbook.humanify(None, events)) + list(logbook.humanify(None, yield_events(event))) return timer() - start diff --git a/requirements_all.txt b/requirements_all.txt index aa1f78ab701..87e9f53bba9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,6 +81,9 @@ TravisPy==0.3.5 # homeassistant.components.notify.twitter TwitterAPI==2.5.9 +# homeassistant.components.tof.sensor +# VL53L1X2==0.1.5 + # homeassistant.components.sensor.waze_travel_time WazeRouteCalculator==0.9 @@ -97,7 +100,7 @@ afsapi==0.0.4 aioambient==0.1.3 # homeassistant.components.asuswrt -aioasuswrt==1.1.20 +aioasuswrt==1.1.21 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -152,13 +155,16 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.amcrest -amcrest==1.2.3 +amcrest==1.2.5 + +# homeassistant.components.androidtv.media_player +androidtv==0.0.12 # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.media_player.anthemav -anthemav==1.1.9 +anthemav==1.1.10 # homeassistant.components.apcupsd apcaccess==0.0.13 @@ -174,7 +180,10 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.14.4 +async-upnp-client==0.14.5 + +# homeassistant.components.stream +av==6.1.2 # homeassistant.components.light.avion # avion==0.10 @@ -262,9 +271,15 @@ buienradar==0.91 # homeassistant.components.calendar.caldav caldav==0.5.0 +# homeassistant.components.cisco_mobility_express.device_tracker +ciscomobilityexpress==0.1.2 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.cppm_tracker.device_tracker +clearpasspy==1.0.2 + # homeassistant.components.sensor.co2signal co2signal==0.4.2 @@ -423,9 +438,6 @@ fiblary3==0.1.7 # homeassistant.components.sensor.fints fints==1.0.1 -# homeassistant.components.media_player.firetv -firetv==1.0.9 - # homeassistant.components.sensor.fitbit fitbit==0.3.0 @@ -511,6 +523,9 @@ habitipy==0.2.0 # homeassistant.components.hangouts hangups==0.4.6 +# homeassistant.components.cloud +hass-nabucasa==0.8 + # homeassistant.components.mqtt.server hbmqtt==0.9.4 @@ -539,23 +554,23 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190305.1 +home-assistant-frontend==20190320.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 # homeassistant.components.homekit_controller -homekit==0.12.2 +homekit[IP]==0.13.0 # homeassistant.components.homematicip_cloud -homematicip==0.10.5 +homematicip==0.10.6 # homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.1.3 +huawei-lte-api==1.1.5 # homeassistant.components.hydrawise hydrawiser==0.1.1 @@ -607,7 +622,7 @@ keyrings.alt==3.1.1 kiwiki-client==0.1.1 # homeassistant.components.konnected -konnected==0.1.4 +konnected==0.1.5 # homeassistant.components.eufy lakeside==0.12 @@ -699,7 +714,7 @@ millheater==0.3.4 mitemp_bt==0.0.1 # homeassistant.components.sensor.mopar -motorparts==1.0.2 +motorparts==1.1.0 # homeassistant.components.tts mutagen==1.42.0 @@ -716,20 +731,17 @@ myusps==1.3.2 # homeassistant.components.media_player.nad nad_receiver==0.0.11 -# homeassistant.components.light.nanoleaf_aurora -nanoleaf==0.4.1 - # homeassistant.components.device_tracker.keenetic_ndms2 ndms2_client==0.0.6 # homeassistant.components.ness_alarm -nessclient==0.9.13 +nessclient==0.9.14 # homeassistant.components.sensor.netdata netdata==0.1.2 # homeassistant.components.discovery -netdisco==2.3.0 +netdisco==2.5.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -753,7 +765,7 @@ nuheat==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.16.1 +numpy==1.16.2 # homeassistant.components.google oauth2client==4.0.0 @@ -774,7 +786,10 @@ openevsewifi==0.4 openhomedevice==0.4.2 # homeassistant.components.air_quality.opensensemap -opensensemap-api==0.1.4 +opensensemap-api==0.1.5 + +# homeassistant.components.enigma2.media_player +openwebifpy==1.2.7 # homeassistant.components.device_tracker.luci openwrt-luci-rpc==1.0.5 @@ -891,7 +906,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.1.1 +py17track==2.2.2 # homeassistant.components.hdmi_cec pyCEC==0.4.13 @@ -965,7 +980,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==2.5.2 +pychromecast==3.0.0 # homeassistant.components.media_player.cmus pycmus==0.1.1 @@ -1004,7 +1019,7 @@ pydukeenergy==0.0.6 pyebox==1.1.4 # homeassistant.components.water_heater.econet -pyeconet==0.0.9 +pyeconet==0.0.10 # homeassistant.components.switch.edimax pyedimax==0.1 @@ -1034,7 +1049,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.sensor.flunearyou -pyflunearyou==1.0.1 +pyflunearyou==1.0.3 # homeassistant.components.light.futurenow pyfnip==0.2 @@ -1068,7 +1083,7 @@ pyhik==0.2.2 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.56 +pyhomematic==0.1.58 # homeassistant.components.homeworks pyhomeworks==0.0.6 @@ -1123,7 +1138,7 @@ pylinky==0.3.0 pylitejet==0.1 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.18 +pyloopenergy==0.1.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.5.0 @@ -1158,6 +1173,9 @@ pymyq==1.1.0 # homeassistant.components.mysensors pymysensors==0.18.0 +# homeassistant.components.light.nanoleaf +pynanoleaf==0.0.5 + # homeassistant.components.lock.nello pynello==2.0.2 @@ -1178,7 +1196,7 @@ pynut2==2.1.2 pynx584==0.4 # homeassistant.components.openuv -pyopenuv==1.0.4 +pyopenuv==1.0.9 # homeassistant.components.light.opple pyoppleio==1.0.5 @@ -1211,10 +1229,10 @@ pypjlink2==1.2.0 pypoint==1.1.1 # homeassistant.components.sensor.pollen -pypollencom==2.2.2 +pypollencom==2.2.3 # homeassistant.components.ps4 -pyps4-homeassistant==0.3.0 +pyps4-homeassistant==0.4.8 # homeassistant.components.qwikswitch pyqwikswitch==0.8 @@ -1256,7 +1274,7 @@ pysher==1.0.1 pysma==0.3.1 # homeassistant.components.smartthings -pysmartapp==0.3.1 +pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.7 @@ -1346,7 +1364,7 @@ python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom -python-mystrom==0.4.4 +python-mystrom==0.5.0 # homeassistant.components.nest python-nest==4.1.0 @@ -1376,7 +1394,7 @@ python-songpal==0.0.9.1 python-synology==0.2.0 # homeassistant.components.tado -python-tado==0.2.3 +python-tado==0.2.8 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 @@ -1385,11 +1403,14 @@ python-telegram-bot==11.1.0 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.21 +python-velbus==2.0.22 # homeassistant.components.media_player.vlc python-vlc==1.1.2 +# homeassistant.components.sensor.whois +python-whois==0.7.1 + # homeassistant.components.wink python-wink==1.10.3 @@ -1402,11 +1423,8 @@ python_opendata_transport==0.1.4 # homeassistant.components.egardia pythonegardia==1.0.39 -# homeassistant.components.sensor.whois -pythonwhois==2.4.3 - # homeassistant.components.device_tracker.tile -pytile==2.0.5 +pytile==2.0.6 # homeassistant.components.climate.touchline pytouchline==0.7 @@ -1439,13 +1457,13 @@ pyuptimerobot==0.0.5 pyvera==0.2.45 # homeassistant.components.switch.vesync -pyvesync==0.1.1 +pyvesync_v2==0.9.6 # homeassistant.components.media_player.vizio pyvizio==0.0.4 # homeassistant.components.velux -pyvlx==0.2.9 +pyvlx==0.2.10 # homeassistant.components.notify.html5 pywebpush==1.6.0 @@ -1454,7 +1472,7 @@ pywebpush==1.6.0 pywemo==0.4.34 # homeassistant.components.camera.xeoma -pyxeoma==1.4.0 +pyxeoma==1.4.1 # homeassistant.components.zabbix pyzabbix==0.7.4 @@ -1466,7 +1484,7 @@ pyzbar==0.1.7 qnapstats==0.2.7 # homeassistant.components.device_tracker.quantum_gateway -quantum-gateway==0.0.3 +quantum-gateway==0.0.5 # homeassistant.components.rachio rachiopy==0.1.3 @@ -1487,7 +1505,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.1.0 +regenmaschine==1.4.0 # homeassistant.components.python_script restrictedpython==4.0b8 @@ -1532,10 +1550,10 @@ rxv==0.6.0 samsungctl[websocket]==0.7.1 # homeassistant.components.satel_integra -satel_integra==0.2.0 +satel_integra==0.3.2 # homeassistant.components.sensor.deutsche_bahn -schiene==0.22 +schiene==0.23 # homeassistant.components.scsgate scsgate==0.1.0 @@ -1548,7 +1566,7 @@ sendgrid==5.6.0 sense-hat==2.2.0 # homeassistant.components.sense -sense_energy==0.6.0 +sense_energy==0.7.0 # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 @@ -1623,7 +1641,7 @@ sqlalchemy==1.2.18 srpenergy==1.0.5 # homeassistant.components.sensor.starlingbank -starlingbank==3.0 +starlingbank==3.1 # homeassistant.components.statsd statsd==3.2.1 @@ -1674,7 +1692,7 @@ temescal==0.1 temperusb==1.5.3 # homeassistant.components.tesla -teslajsonpy==0.0.23 +teslajsonpy==0.0.25 # homeassistant.components.sensor.thermoworks_smoke thermoworks_smoke==0.1.8 @@ -1689,10 +1707,10 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.2.1 +toonapilib==3.2.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.22 +total_connect_client==0.24 # homeassistant.components.tplink_lte tp-connected==0.0.4 @@ -1751,9 +1769,6 @@ wakeonlan==1.1.6 # homeassistant.components.sensor.waqi waqiasync==1.0.0 -# homeassistant.components.cloud -warrant==0.6.1 - # homeassistant.components.folder_watcher watchdog==0.8.3 @@ -1778,8 +1793,11 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.device_tracker.xfinity +xfinity-gateway==0.0.4 + # homeassistant.components.knx -xknx==0.9.4 +xknx==0.10.0 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca @@ -1805,7 +1823,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.02.18 +youtube_dl==2019.03.01 # homeassistant.components.light.zengge zengge==0.2 @@ -1814,7 +1832,7 @@ zengge==0.2 zeroconf==0.21.3 # homeassistant.components.zha -zha-quirks==0.0.6 +zha-quirks==0.0.7 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test.txt b/requirements_test.txt index 531fb0b78f6..9aa5d7d5c91 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ flake8==3.7.7 mock-open==1.3.1 mypy==0.670 pydocstyle==3.0.0 -pylint==2.3.0 +pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.1 pytest-sugar==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 073768d0cf2..935bc5689e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -9,7 +9,7 @@ flake8==3.7.7 mock-open==1.3.1 mypy==0.670 pydocstyle==3.0.0 -pylint==2.3.0 +pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.1 pytest-sugar==0.9.2 @@ -53,6 +53,9 @@ aiounifi==4 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.stream +av==6.1.2 + # homeassistant.components.zha bellows-homeassistant==0.7.1 @@ -110,6 +113,9 @@ ha-ffmpeg==1.11 # homeassistant.components.hangouts hangups==0.4.6 +# homeassistant.components.cloud +hass-nabucasa==0.8 + # homeassistant.components.mqtt.server hbmqtt==0.9.4 @@ -120,13 +126,13 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190305.1 +home-assistant-frontend==20190320.0 # homeassistant.components.homekit_controller -homekit==0.12.2 +homekit[IP]==0.13.0 # homeassistant.components.homematicip_cloud -homematicip==0.10.5 +homematicip==0.10.6 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -155,7 +161,7 @@ mficlient==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.16.1 +numpy==1.16.2 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -197,7 +203,7 @@ pydeconz==52 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.56 +pyhomematic==0.1.58 # homeassistant.components.litejet pylitejet==0.1 @@ -210,7 +216,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.openuv -pyopenuv==1.0.4 +pyopenuv==1.0.9 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp @@ -218,13 +224,13 @@ pyopenuv==1.0.4 pyotp==2.2.6 # homeassistant.components.ps4 -pyps4-homeassistant==0.3.0 +pyps4-homeassistant==0.4.8 # homeassistant.components.qwikswitch pyqwikswitch==0.8 # homeassistant.components.smartthings -pysmartapp==0.3.1 +pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.7 @@ -245,9 +251,6 @@ python-nest==4.1.0 # homeassistant.components.sensor.awair python_awair==0.0.3 -# homeassistant.components.sensor.whois -pythonwhois==2.4.3 - # homeassistant.components.tradfri pytradfri[async]==6.0.1 @@ -258,7 +261,7 @@ pyunifi==2.16 pywebpush==1.6.0 # homeassistant.components.rainmachine -regenmaschine==1.1.0 +regenmaschine==1.4.0 # homeassistant.components.python_script restrictedpython==4.0b8 @@ -295,7 +298,7 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.2.1 +toonapilib==3.2.2 # homeassistant.components.camera.uvc uvcclient==0.11.0 @@ -312,8 +315,5 @@ vultr==0.1.2 # homeassistant.components.switch.wake_on_lan wakeonlan==1.1.6 -# homeassistant.components.cloud -warrant==0.6.1 - # homeassistant.components.zha zigpy-homeassistant==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7db76b1361b..ada84d2bbcd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -24,6 +24,7 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'opencv-python', 'py_noaa', + 'VL53L1X2', 'pybluez', 'pycups', 'PySwitchbot', @@ -44,6 +45,7 @@ TEST_REQUIREMENTS = ( 'aiohue', 'aiounifi', 'apns2', + 'av', 'caldav', 'coinmarketcap', 'defusedxml', @@ -61,12 +63,13 @@ TEST_REQUIREMENTS = ( 'ha-ffmpeg', 'hangups', 'HAP-python', + 'hass-nabucasa', 'haversine', 'hbmqtt', 'hdate', 'holidays', 'home-assistant-frontend', - 'homekit', + 'homekit[IP]', 'homematicip', 'influxdb', 'jsonpath', @@ -103,7 +106,7 @@ TEST_REQUIREMENTS = ( 'python-forecastio', 'python-nest', 'python_awair', - 'pytradfri\\[async\\]', + 'pytradfri[async]', 'pyunifi', 'pyupnp-async', 'pywebpush', @@ -135,9 +138,10 @@ TEST_REQUIREMENTS = ( ) IGNORE_PACKAGES = ( - 'homeassistant.components.recorder.models', + 'homeassistant.components.hangouts.hangups_utils', + 'homeassistant.components.cloud.client', 'homeassistant.components.homekit.*', - 'homeassistant.components.hangouts.hangups_utils' + 'homeassistant.components.recorder.models', ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') @@ -287,7 +291,7 @@ def requirements_test_output(reqs): output.append('\n') filtered = {key: value for key, value in reqs.items() if any( - re.search(r'(^|#){}($|[=><])'.format(ign), + re.search(r'(^|#){}($|[=><])'.format(re.escape(ign)), key) is not None for ign in TEST_REQUIREMENTS)} output.append(generate_requirements_list(filtered)) diff --git a/script/translations_download b/script/translations_download index 9363bc425ae..2fa16604af1 100755 --- a/script/translations_download +++ b/script/translations_download @@ -28,6 +28,7 @@ mkdir -p ${LOCAL_DIR} docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ + --rm \ lokalise/lokalise-cli@sha256:b8329d20280263cad04f65b843e54b9e8e6909a348a678eac959550b5ef5c75f lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 1fd70668f8b..119deac3311 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -6,8 +6,9 @@ from homeassistant.auth.permissions.entities import ( compile_entities, ENTITY_POLICY_SCHEMA) from homeassistant.auth.permissions.models import PermissionLookup from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.device_registry import DeviceEntry -from tests.common import mock_registry +from tests.common import mock_registry, mock_device_registry def test_entities_none(): @@ -193,7 +194,7 @@ def test_entities_all_control(): def test_entities_device_id_boolean(hass): """Test entity ID policy applying control on device id.""" - registry = mock_registry(hass, { + entity_registry = mock_registry(hass, { 'test_domain.allowed': RegistryEntry( entity_id='test_domain.allowed', unique_id='1234', @@ -207,6 +208,7 @@ def test_entities_device_id_boolean(hass): device_id='mock-not-allowed-dev-id' ), }) + device_registry = mock_device_registry(hass) policy = { 'device_ids': { @@ -216,8 +218,55 @@ def test_entities_device_id_boolean(hass): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy, PermissionLookup(registry)) + compiled = compile_entities(policy, PermissionLookup( + entity_registry, device_registry + )) assert compiled('test_domain.allowed', 'read') is True assert compiled('test_domain.allowed', 'control') is False assert compiled('test_domain.not_allowed', 'read') is False assert compiled('test_domain.not_allowed', 'control') is False + + +def test_entities_areas_true(): + """Test entity ID policy for areas.""" + policy = { + 'area_ids': True + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, None) + assert compiled('light.kitchen', 'read') is True + + +def test_entities_areas_area_true(hass): + """Test entity ID policy for areas with specific area.""" + entity_registry = mock_registry(hass, { + 'light.kitchen': RegistryEntry( + entity_id='light.kitchen', + unique_id='1234', + platform='test_platform', + device_id='mock-dev-id' + ), + }) + device_registry = mock_device_registry(hass, { + 'mock-dev-id': DeviceEntry( + id='mock-dev-id', + area_id='mock-area-id' + ) + }) + + policy = { + 'area_ids': { + 'mock-area-id': { + 'read': True, + 'control': True, + } + } + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, PermissionLookup( + entity_registry, device_registry + )) + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False + assert compiled('switch.kitchen', 'read') is False diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py index f6a68f0865a..dc2f1cd0d54 100644 --- a/tests/auth/permissions/test_system_policies.py +++ b/tests/auth/permissions/test_system_policies.py @@ -14,6 +14,17 @@ def test_admin_policy(): assert perms.check_entity('light.kitchen', 'edit') +def test_user_policy(): + """Test user policy works.""" + # Make sure it's valid + POLICY_SCHEMA(system_policies.USER_POLICY) + + perms = PolicyPermissions(system_policies.USER_POLICY, None) + assert perms.check_entity('light.kitchen', 'read') + assert perms.check_entity('light.kitchen', 'control') + assert perms.check_entity('light.kitchen', 'edit') + + def test_read_only_policy(): """Test read only policy works.""" # Make sure it's valid diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 96da624161a..3f4c257f000 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -1,6 +1,4 @@ """Tests for the legacy_api_password auth provider.""" -from unittest.mock import Mock - import pytest from homeassistant import auth, data_entry_flow @@ -19,6 +17,7 @@ def provider(hass, store): """Mock provider.""" return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, { 'type': 'legacy_api_password', + 'api_password': 'test-password', }) @@ -51,32 +50,13 @@ async def test_only_one_credentials(manager, provider): async def test_verify_login(hass, provider): """Test login using legacy api password auth provider.""" - hass.http = Mock(api_password='test-password') provider.async_validate_login('test-password') - hass.http = Mock(api_password='test-password') with pytest.raises(legacy_api_password.InvalidAuthError): provider.async_validate_login('invalid-password') -async def test_login_flow_abort(hass, manager): - """Test wrong config.""" - for http in ( - None, - Mock(api_password=None), - Mock(api_password=''), - ): - hass.http = http - - result = await manager.login_flow.async_init( - handler=('legacy_api_password', None) - ) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'no_api_password_set' - - async def test_login_flow_works(hass, manager): """Test wrong config.""" - hass.http = Mock(api_password='hello') result = await manager.login_flow.async_init( handler=('legacy_api_password', None) ) @@ -94,7 +74,7 @@ async def test_login_flow_works(hass, manager): result = await manager.login_flow.async_configure( flow_id=result['flow_id'], user_input={ - 'password': 'hello' + 'password': 'test-password' } ) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 7e9df869a04..32c314b56d6 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,4 +1,8 @@ """Tests for the auth store.""" +import asyncio + +import asynctest + from homeassistant.auth import auth_store @@ -60,7 +64,7 @@ async def test_loading_no_group_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated @@ -69,6 +73,10 @@ async def test_loading_no_group_data_format(hass, hass_storage): assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY + user_group = groups[2] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER users = await store.async_get_users() assert len(users) == 2 @@ -153,7 +161,7 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated @@ -162,6 +170,10 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY + user_group = groups[2] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER users = await store.async_get_users() assert len(users) == 2 @@ -185,12 +197,16 @@ async def test_loading_empty_data(hass, hass_storage): """Test we correctly load with no existing data.""" store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated assert admin_group.id == auth_store.GROUP_ID_ADMIN - read_group = groups[1] + user_group = groups[1] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER + read_group = groups[2] assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY @@ -213,8 +229,33 @@ async def test_system_groups_store_id_and_name(hass, hass_storage): 'id': auth_store.GROUP_ID_ADMIN, 'name': auth_store.GROUP_NAME_ADMIN, }, + { + 'id': auth_store.GROUP_ID_USER, + 'name': auth_store.GROUP_NAME_USER, + }, { 'id': auth_store.GROUP_ID_READ_ONLY, 'name': auth_store.GROUP_NAME_READ_ONLY, }, ] + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + store = auth_store.AuthStore(hass) + with asynctest.patch( + 'homeassistant.helpers.entity_registry.async_get_registry', + ) as mock_ent_registry, asynctest.patch( + 'homeassistant.helpers.device_registry.async_get_registry', + ) as mock_dev_registry, asynctest.patch( + 'homeassistant.helpers.storage.Store.async_load', + ) as mock_load: + results = await asyncio.gather( + store.async_get_users(), + store.async_get_users(), + ) + + mock_ent_registry.assert_called_once_with(hass) + mock_dev_registry.assert_called_once_with(hass) + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/common.py b/tests/common.py index a55546da73b..8681db1b4f3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -452,7 +452,7 @@ class MockModule: requirements=None, config_schema=None, platform_schema=None, platform_schema_base=None, async_setup=None, async_setup_entry=None, async_unload_entry=None, - async_migrate_entry=None): + async_migrate_entry=None, async_remove_entry=None): """Initialize the mock module.""" self.__name__ = 'homeassistant.components.{}'.format(domain) self.DOMAIN = domain @@ -487,6 +487,9 @@ class MockModule: if async_migrate_entry is not None: self.async_migrate_entry = async_migrate_entry + if async_remove_entry is not None: + self.async_remove_entry = async_remove_entry + class MockPlatform: """Provide a fake platform.""" diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index a88c828efe8..c4f227e488b 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -407,14 +407,10 @@ def _listen_count(hass): async def test_api_error_log(hass, aiohttp_client, hass_access_token, - hass_admin_user, legacy_auth): + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' - await async_setup_component(hass, 'api', { - 'http': { - 'api_password': 'yolo' - } - }) + await async_setup_component(hass, 'api', {}) client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index 4b669fc1356..8ca7f6b13f5 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -41,7 +41,7 @@ async def test_if_fires_on_event(hass, calls): hass.bus.async_fire('test_event', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id await common.async_turn_off(hass) await hass.async_block_till_done() diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py index 928296c8d27..92ded1a07db 100644 --- a/tests/components/automation/test_geo_location.py +++ b/tests/components/automation/test_geo_location.py @@ -68,7 +68,7 @@ async def test_if_fires_on_zone_enter(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'geo_location - geo_location.entity - hello - hello - test' == \ calls[0].data['some'] @@ -221,7 +221,7 @@ async def test_if_fires_on_zone_appear(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'geo_location - geo_location.entity - - hello - test' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 12c97507a13..a019f65afcf 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -369,38 +369,47 @@ async def test_shared_context(hass, calls): }) context = Context() - automation_mock = Mock() + first_automation_listener = Mock() event_mock = Mock() - hass.bus.async_listen('test_event2', automation_mock) + hass.bus.async_listen('test_event2', first_automation_listener) hass.bus.async_listen(EVENT_AUTOMATION_TRIGGERED, event_mock) hass.bus.async_fire('test_event', context=context) await hass.async_block_till_done() # Ensure events was fired - assert automation_mock.call_count == 1 + assert first_automation_listener.call_count == 1 assert event_mock.call_count == 2 - # Ensure context carries through the event - args, kwargs = automation_mock.call_args - assert args[0].context == context + # Verify automation triggered evenet for 'hello' automation + args, kwargs = event_mock.call_args_list[0] + first_trigger_context = args[0].context + assert first_trigger_context.parent_id == context.id + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None - for call in event_mock.call_args_list: - args, kwargs = call - assert args[0].context == context - # Ensure event data has all attributes set - assert args[0].data.get(ATTR_NAME) is not None - assert args[0].data.get(ATTR_ENTITY_ID) is not None + # Ensure context set correctly for event fired by 'hello' automation + args, kwargs = first_automation_listener.call_args + assert args[0].context is first_trigger_context - # Ensure the automation state shares the same context + # Ensure the 'hello' automation state has the right context state = hass.states.get('automation.hello') assert state is not None - assert state.context == context + assert state.context is first_trigger_context + + # Verify automation triggered evenet for 'bye' automation + args, kwargs = event_mock.call_args_list[1] + second_trigger_context = args[0].context + assert second_trigger_context.parent_id == first_trigger_context.id + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None # Ensure the service call from the second automation # shares the same context assert len(calls) == 1 - assert calls[0].context == context + assert calls[0].context is second_trigger_context async def test_services(hass, calls): diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 92a5f3b8b92..803a15e9634 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -45,7 +45,7 @@ async def test_if_fires_on_entity_change_below(hass, calls): hass.states.async_set('test.entity', 9, context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id # Set above 12 so the automation will fire again hass.states.async_set('test.entity', 12) @@ -134,7 +134,7 @@ async def test_if_not_fires_on_entity_change_below_to_below(hass, calls): hass.states.async_set('test.entity', 9, context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id # already below so should not fire again hass.states.async_set('test.entity', 5) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index abe02638f26..53c1eaab3d9 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -55,7 +55,7 @@ async def test_if_fires_on_entity_change(hass, calls): hass.states.async_set('test.entity', 'world', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'state - test.entity - hello - world - None' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index c326c7f03f4..f803f97f4ab 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -257,7 +257,7 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): hass.states.async_set('test.entity', 'world', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'template - test.entity - hello - world' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index 04ffeaf13aa..d5bfd9fdf88 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -66,7 +66,7 @@ async def test_if_fires_on_zone_enter(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'zone - test.entity - hello - hello - test' == \ calls[0].data['some'] diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 6b98f378ef0..840e30161f3 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,12 +1,12 @@ """The tests for the camera component.""" import asyncio import base64 -from unittest.mock import patch, mock_open +from unittest.mock import patch, mock_open, PropertyMock import pytest from homeassistant.setup import setup_component, async_setup_component -from homeassistant.const import ATTR_ENTITY_PICTURE +from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) from homeassistant.components import camera, http from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.exceptions import HomeAssistantError @@ -16,6 +16,7 @@ from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, mock_coro) from tests.components.camera import common +from tests.components.stream.common import generate_h264_video @pytest.fixture @@ -32,6 +33,14 @@ def mock_camera(hass): yield +@pytest.fixture +def mock_stream(hass): + """Initialize a demo camera platform with streaming.""" + assert hass.loop.run_until_complete(async_setup_component(hass, 'stream', { + 'stream': {} + })) + + class TestSetupCamera: """Test class for setup camera.""" @@ -156,3 +165,88 @@ async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): assert msg['result']['content_type'] == 'image/jpeg' assert msg['result']['content'] == \ base64.b64encode(b'Test').decode('utf-8') + + +async def test_webocket_stream_no_source(hass, hass_ws_client, + mock_camera, mock_stream): + """Test camera/stream websocket command.""" + await async_setup_component(hass, 'camera') + + with patch('homeassistant.components.camera.request_stream', + return_value='http://home.assistant/playlist.m3u8') \ + as mock_request_stream: + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 6, + 'type': 'camera/stream', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert not mock_request_stream.called + assert msg['id'] == 6 + assert msg['type'] == TYPE_RESULT + assert not msg['success'] + + +async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, + mock_camera, mock_stream): + """Test camera/stream websocket command.""" + await async_setup_component(hass, 'camera') + + with patch('homeassistant.components.camera.request_stream', + return_value='http://home.assistant/playlist.m3u8' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.demo.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = generate_h264_video() + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 6, + 'type': 'camera/stream', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert mock_request_stream.called + assert msg['id'] == 6 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result']['url'][-13:] == 'playlist.m3u8' + + +async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): + """Test camera play_stream service.""" + data = { + ATTR_ENTITY_ID: 'camera.demo_camera', + camera.ATTR_MEDIA_PLAYER: 'media_player.test' + } + with patch('homeassistant.components.camera.request_stream'), \ + pytest.raises(HomeAssistantError): + # Call service + await hass.services.async_call( + camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) + + +async def test_handle_play_stream_service(hass, mock_camera, mock_stream): + """Test camera play_stream service.""" + await async_setup_component(hass, 'media_player') + data = { + ATTR_ENTITY_ID: 'camera.demo_camera', + camera.ATTR_MEDIA_PLAYER: 'media_player.test' + } + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.demo.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = generate_h264_video() + # Call service + await hass.services.async_call( + camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) + # So long as we request the stream, the rest should be covered + # by the play_media service tests. + assert mock_request_stream.called diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index b5d6220904f..ff81c056420 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -222,13 +222,6 @@ async def test_normal_chromecast_not_starting_discovery(hass): assert setup_discovery.call_count == 1 -async def test_normal_raises_platform_not_ready(hass): - """Test cast platform raises PlatformNotReady if HTTP dial fails.""" - with patch('pychromecast.dial.get_device_status', return_value=None): - with pytest.raises(PlatformNotReady): - await async_setup_cast(hass, {'host': 'host1'}) - - async def test_replay_past_chromecasts(hass): """Test cast platform re-playing past chromecasts when adding new one.""" cast_group1 = get_fake_chromecast_info(host='host1', port=42) @@ -262,6 +255,10 @@ async def test_entity_media_states(hass: HomeAssistantType): return_value=full_info): chromecast, entity = await async_setup_media_player_cast(hass, info) + entity._available = True + entity.schedule_update_ha_state() + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') assert state is not None assert state.name == 'Speaker' @@ -275,16 +272,16 @@ async def test_entity_media_states(hass: HomeAssistantType): state = hass.states.get('media_player.speaker') assert state.state == 'playing' - entity.new_media_status(media_status) media_status.player_is_playing = False media_status.player_is_paused = True + entity.new_media_status(media_status) await hass.async_block_till_done() state = hass.states.get('media_player.speaker') assert state.state == 'paused' - entity.new_media_status(media_status) media_status.player_is_paused = False media_status.player_is_idle = True + entity.new_media_status(media_status) await hass.async_block_till_done() state = hass.states.get('media_player.speaker') assert state.state == 'idle' diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index ba63e43d091..3a07e52724f 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -11,8 +11,7 @@ from tests.common import mock_coro def mock_cloud(hass, config={}): """Mock cloud.""" - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): assert hass.loop.run_until_complete(async_setup_component( hass, cloud.DOMAIN, { 'cloud': config @@ -30,5 +29,5 @@ def mock_cloud_prefs(hass, prefs={}): const.PREF_GOOGLE_ALLOW_UNLOCK: True, } prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set + hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set return prefs_to_set diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 81ecb7250ef..163754dd3e1 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,9 +1,18 @@ """Fixtures for cloud tests.""" import pytest +from unittest.mock import patch + from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(autouse=True) +def mock_user_data(): + """Mock os module.""" + with patch('hass_nabucasa.Cloud.write_user_info') as writer: + yield writer + + @pytest.fixture def mock_cloud_fixture(hass): """Fixture for cloud component.""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py deleted file mode 100644 index bdf9939cb2b..00000000000 --- a/tests/components/cloud/test_auth_api.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Tests for the tools to communicate with the cloud.""" -import asyncio -from unittest.mock import MagicMock, patch - -from botocore.exceptions import ClientError -import pytest - -from homeassistant.components.cloud import auth_api - - -@pytest.fixture -def mock_cognito(): - """Mock warrant.""" - with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: - yield mock_cog() - - -def aws_error(code, message='Unknown', operation_name='fake_operation_name'): - """Generate AWS error response.""" - response = { - 'Error': { - 'Code': code, - 'Message': message - } - } - return ClientError(response, operation_name) - - -def test_login_invalid_auth(mock_cognito): - """Test trying to login with invalid credentials.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') - - with pytest.raises(auth_api.Unauthenticated): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login_user_not_found(mock_cognito): - """Test trying to login with invalid credentials.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') - - with pytest.raises(auth_api.UserNotFound): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login_user_not_confirmed(mock_cognito): - """Test trying to login without confirming account.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = \ - aws_error('UserNotConfirmedException') - - with pytest.raises(auth_api.UserNotConfirmed): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login(mock_cognito): - """Test trying to login without confirming account.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.id_token = 'test_id_token' - mock_cognito.access_token = 'test_access_token' - mock_cognito.refresh_token = 'test_refresh_token' - - auth_api.login(cloud, 'user', 'pass') - - assert len(mock_cognito.authenticate.mock_calls) == 1 - assert cloud.id_token == 'test_id_token' - assert cloud.access_token == 'test_access_token' - assert cloud.refresh_token == 'test_refresh_token' - assert len(cloud.write_user_info.mock_calls) == 1 - - -def test_register(mock_cognito): - """Test registering an account.""" - cloud = MagicMock() - cloud = MagicMock() - auth_api.register(cloud, 'email@home-assistant.io', 'password') - assert len(mock_cognito.register.mock_calls) == 1 - result_user, result_password = mock_cognito.register.mock_calls[0][1] - assert result_user == 'email@home-assistant.io' - assert result_password == 'password' - - -def test_register_fails(mock_cognito): - """Test registering an account.""" - cloud = MagicMock() - mock_cognito.register.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.register(cloud, 'email@home-assistant.io', 'password') - - -def test_resend_email_confirm(mock_cognito): - """Test starting forgot password flow.""" - cloud = MagicMock() - auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 - - -def test_resend_email_confirm_fails(mock_cognito): - """Test failure when starting forgot password flow.""" - cloud = MagicMock() - mock_cognito.client.resend_confirmation_code.side_effect = \ - aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') - - -def test_forgot_password(mock_cognito): - """Test starting forgot password flow.""" - cloud = MagicMock() - auth_api.forgot_password(cloud, 'email@home-assistant.io') - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 - - -def test_forgot_password_fails(mock_cognito): - """Test failure when starting forgot password flow.""" - cloud = MagicMock() - mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.forgot_password(cloud, 'email@home-assistant.io') - - -def test_check_token_writes_new_token_on_refresh(mock_cognito): - """Test check_token writes new token if refreshed.""" - cloud = MagicMock() - mock_cognito.check_token.return_value = True - mock_cognito.id_token = 'new id token' - mock_cognito.access_token = 'new access token' - - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token == 'new id token' - assert cloud.access_token == 'new access token' - assert len(cloud.write_user_info.mock_calls) == 1 - - -def test_check_token_does_not_write_existing_token(mock_cognito): - """Test check_token won't write new token if still valid.""" - cloud = MagicMock() - mock_cognito.check_token.return_value = False - - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token != mock_cognito.id_token - assert cloud.access_token != mock_cognito.access_token - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_check_token_raises(mock_cognito): - """Test we raise correct error.""" - cloud = MagicMock() - mock_cognito.check_token.side_effect = aws_error('SomeError') - - with pytest.raises(auth_api.CloudError): - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token != mock_cognito.id_token - assert cloud.access_token != mock_cognito.access_token - assert len(cloud.write_user_info.mock_calls) == 0 - - -async def test_async_setup(hass): - """Test async setup.""" - cloud = MagicMock() - await auth_api.async_setup(hass, cloud) - assert len(cloud.iot.mock_calls) == 2 - on_connect = cloud.iot.mock_calls[0][1][0] - on_disconnect = cloud.iot.mock_calls[1][1][0] - - with patch('random.randint', return_value=0), patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' - ) as mock_renew: - await on_connect() - # Let handle token sleep once - await asyncio.sleep(0) - # Let handle token refresh token - await asyncio.sleep(0) - - assert len(mock_renew.mock_calls) == 1 - assert mock_renew.mock_calls[0][1][0] is cloud - - await on_disconnect() - - # Make sure task is no longer being called - await asyncio.sleep(0) - await asyncio.sleep(0) - assert len(mock_renew.mock_calls) == 1 diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py new file mode 100644 index 00000000000..938829b809b --- /dev/null +++ b/tests/components/cloud/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the cloud binary sensor.""" +from unittest.mock import Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE + + +async def test_remote_connection_sensor(hass): + """Test the remote connection sensor.""" + assert await async_setup_component(hass, 'cloud', {'cloud': {}}) + cloud = hass.data['cloud'] = Mock() + cloud.remote.certificate = None + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state is not None + assert state.state == 'unavailable' + + cloud.remote.is_connected = False + cloud.remote.certificate = object() + hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {}) + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state.state == 'off' + + cloud.remote.is_connected = True + hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {}) + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state.state == 'on' diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py new file mode 100644 index 00000000000..4440651d089 --- /dev/null +++ b/tests/components/cloud/test_client.py @@ -0,0 +1,199 @@ +"""Test the cloud.iot module.""" +from unittest.mock import patch, MagicMock + +from aiohttp import web +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.cloud.const import ( + PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from tests.components.alexa import test_smart_home as test_alexa +from tests.common import mock_coro + +from . import mock_cloud_prefs + + +@pytest.fixture +def mock_cloud(): + """Mock cloud class.""" + return MagicMock(subscription_expired=False) + + +async def test_handler_alexa(hass): + """Test handler Alexa.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' + } + } + } + } + }) + assert setup + + mock_cloud_prefs(hass) + cloud = hass.data['cloud'] + + resp = await cloud.client.async_alexa_message( + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + endpoints = resp['event']['payload']['endpoints'] + + assert len(endpoints) == 1 + device = endpoints[0] + + assert device['description'] == 'Config description' + assert device['friendlyName'] == 'Config name' + assert device['displayCategories'] == ['LIGHT'] + assert device['manufacturerName'] == 'Home Assistant' + + +async def test_handler_alexa_disabled(hass, mock_cloud_fixture): + """Test handler Alexa when user has disabled it.""" + mock_cloud_fixture[PREF_ENABLE_ALEXA] = False + cloud = hass.data['cloud'] + + resp = await cloud.client.async_alexa_message( + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + assert resp['event']['header']['namespace'] == 'Alexa' + assert resp['event']['header']['name'] == 'ErrorResponse' + assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' + + +async def test_handler_google_actions(hass): + """Test handler Google Actions.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + hass.states.async_set( + 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'aliases': 'Config alias', + 'room': 'living room' + } + } + } + } + }) + assert setup + + mock_cloud_prefs(hass) + cloud = hass.data['cloud'] + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + with patch( + 'hass_nabucasa.Cloud._decode_claims', + return_value={'cognito:username': 'myUserName'} + ): + resp = await cloud.client.async_google_message(data) + + assert resp['requestId'] == reqid + payload = resp['payload'] + + assert payload['agentUserId'] == 'myUserName' + + devices = payload['devices'] + assert len(devices) == 1 + + device = devices[0] + assert device['id'] == 'switch.test' + assert device['name']['name'] == 'Config name' + assert device['name']['nicknames'] == ['Config alias'] + assert device['type'] == 'action.devices.types.SWITCH' + assert device['roomHint'] == 'living room' + + +async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): + """Test handler Google Actions when user has disabled it.""" + mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + assert await async_setup_component(hass, 'cloud', {}) + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + cloud = hass.data['cloud'] + resp = await cloud.client.async_google_message(data) + + assert resp['requestId'] == reqid + assert resp['payload']['errorCode'] == 'deviceTurnedOff' + + +async def test_webhook_msg(hass): + """Test webhook msg.""" + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': {} + }) + assert setup + cloud = hass.data['cloud'] + + await cloud.client.prefs.async_initialize() + await cloud.client.prefs.async_update(cloudhooks={ + 'hello': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id' + } + }) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({'from': 'handler'}) + + hass.components.webhook.async_register( + 'test', 'Test', 'mock-webhook-id', handler) + + response = await cloud.client.async_webhook_message({ + 'cloudhook_id': 'mock-cloud-id', + 'body': '{"hello": "world"}', + 'headers': { + 'content-type': 'application/json' + }, + 'method': 'POST', + 'query': None, + }) + + assert response == { + 'status': 200, + 'body': '{"from": "handler"}', + 'headers': { + 'Content-Type': 'application/json' + } + } + + assert len(received) == 1 + assert await received[0].json() == { + 'hello': 'world' + } diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py deleted file mode 100644 index 0ddb8ecce50..00000000000 --- a/tests/components/cloud/test_cloud_api.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test cloud API.""" -from unittest.mock import Mock, patch - -import pytest - -from homeassistant.components.cloud import cloud_api - - -@pytest.fixture(autouse=True) -def mock_check_token(): - """Mock check token.""" - with patch('homeassistant.components.cloud.auth_api.' - 'check_token') as mock_check_token: - yield mock_check_token - - -async def test_create_cloudhook(hass, aioclient_mock): - """Test creating a cloudhook.""" - aioclient_mock.post('https://example.com/bla', json={ - 'cloudhook_id': 'mock-webhook', - 'url': 'https://blabla' - }) - cloud = Mock( - hass=hass, - id_token='mock-id-token', - cloudhook_create_url='https://example.com/bla', - ) - resp = await cloud_api.async_create_cloudhook(cloud) - assert len(aioclient_mock.mock_calls) == 1 - assert await resp.json() == { - 'cloudhook_id': 'mock-webhook', - 'url': 'https://blabla' - } diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py deleted file mode 100644 index 9306a6c6ef3..00000000000 --- a/tests/components/cloud/test_cloudhooks.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Test cloud cloudhooks.""" -from unittest.mock import Mock - -import pytest - -from homeassistant.components.cloud import prefs, cloudhooks - -from tests.common import mock_coro - - -@pytest.fixture -def mock_cloudhooks(hass): - """Mock cloudhooks class.""" - cloud = Mock() - cloud.hass = hass - cloud.hass.async_add_executor_job = Mock(return_value=mock_coro()) - cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro())) - cloud.cloudhook_create_url = 'https://webhook-create.url' - cloud.prefs = prefs.CloudPreferences(hass) - hass.loop.run_until_complete(cloud.prefs.async_initialize()) - return cloudhooks.Cloudhooks(cloud) - - -async def test_enable(mock_cloudhooks, aioclient_mock): - """Test enabling cloudhooks.""" - aioclient_mock.post('https://webhook-create.url', json={ - 'cloudhook_id': 'mock-cloud-id', - 'url': 'https://hooks.nabu.casa/ZXCZCXZ', - }) - - hook = { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id', - 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', - } - - assert hook == await mock_cloudhooks.async_create('mock-webhook-id') - - assert mock_cloudhooks.cloud.prefs.cloudhooks == { - 'mock-webhook-id': hook - } - - publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls - assert len(publish_calls) == 1 - assert publish_calls[0][1][0] == 'webhook-register' - assert publish_calls[0][1][1] == { - 'cloudhook_ids': ['mock-cloud-id'] - } - - -async def test_disable(mock_cloudhooks): - """Test disabling cloudhooks.""" - mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = { - 'mock-webhook-id': { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id', - 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', - } - } - - await mock_cloudhooks.async_delete('mock-webhook-id') - - assert mock_cloudhooks.cloud.prefs.cloudhooks == {} - - publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls - assert len(publish_calls) == 1 - assert publish_calls[0][1][0] == 'webhook-register' - assert publish_calls[0][1][1] == { - 'cloudhook_ids': [] - } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 06de6bf0b59..6c50a158cad 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -4,12 +4,12 @@ from unittest.mock import patch, MagicMock import pytest from jose import jwt +from hass_nabucasa.auth import Unauthenticated, UnknownError +from hass_nabucasa.const import STATE_CONNECTED -from homeassistant.components.cloud import ( - DOMAIN, auth_api, iot) +from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( - PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) -from homeassistant.util import dt as dt_util + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) from tests.common import mock_coro @@ -22,12 +22,21 @@ SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info' @pytest.fixture() def mock_auth(): """Mock check token.""" - with patch('homeassistant.components.cloud.auth_api.check_token'): + with patch('hass_nabucasa.auth.CognitoAuth.check_token'): yield +@pytest.fixture() +def mock_cloud_login(hass, setup_api): + """Mock cloud is logged in.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + + @pytest.fixture(autouse=True) -def setup_api(hass): +def setup_api(hass, aioclient_mock): """Initialize HTTP API.""" mock_cloud(hass, { 'mode': 'development', @@ -54,14 +63,14 @@ def setup_api(hass): @pytest.fixture def cloud_client(hass, hass_client): """Fixture that can fetch from the cloud client.""" - with patch('homeassistant.components.cloud.Cloud.write_user_info'): + with patch('hass_nabucasa.Cloud.write_user_info'): yield hass.loop.run_until_complete(hass_client()) @pytest.fixture def mock_cognito(): """Mock warrant.""" - with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + with patch('hass_nabucasa.auth.CognitoAuth._cognito') as mock_cog: yield mock_cog() @@ -80,8 +89,7 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client, assert req.status == 403 -@asyncio.coroutine -def test_login_view(hass, cloud_client, mock_cognito): +async def test_login_view(hass, cloud_client, mock_cognito): """Test logging in.""" mock_cognito.id_token = jwt.encode({ 'email': 'hello@home-assistant.io', @@ -90,23 +98,22 @@ def test_login_view(hass, cloud_client, mock_cognito): mock_cognito.access_token = 'access_token' mock_cognito.refresh_token = 'refresh_token' - with patch('homeassistant.components.cloud.iot.CloudIoT.' - 'connect') as mock_connect, \ - patch('homeassistant.components.cloud.auth_api._authenticate', + with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \ + patch('hass_nabucasa.auth.CognitoAuth._authenticate', return_value=mock_cognito) as mock_auth: - req = yield from cloud_client.post('/api/cloud/login', json={ + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) assert req.status == 200 - result = yield from req.json() + result = await req.json() assert result == {'success': True} assert len(mock_connect.mock_calls) == 1 assert len(mock_auth.mock_calls) == 1 - cloud, result_user, result_pass = mock_auth.mock_calls[0][1] + result_user, result_pass = mock_auth.mock_calls[0][1] assert result_user == 'my_username' assert result_pass == 'my_password' @@ -123,32 +130,29 @@ async def test_login_view_random_exception(cloud_client): assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'} -@asyncio.coroutine -def test_login_view_invalid_json(cloud_client): +async def test_login_view_invalid_json(cloud_client): """Try logging in with invalid JSON.""" - with patch('homeassistant.components.cloud.auth_api.login') as mock_login: - req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login: + req = await cloud_client.post('/api/cloud/login', data='Not JSON') assert req.status == 400 assert len(mock_login.mock_calls) == 0 -@asyncio.coroutine -def test_login_view_invalid_schema(cloud_client): +async def test_login_view_invalid_schema(cloud_client): """Try logging in with invalid schema.""" - with patch('homeassistant.components.cloud.auth_api.login') as mock_login: - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login: + req = await cloud_client.post('/api/cloud/login', json={ 'invalid': 'schema' }) assert req.status == 400 assert len(mock_login.mock_calls) == 0 -@asyncio.coroutine -def test_login_view_request_timeout(cloud_client): +async def test_login_view_request_timeout(cloud_client): """Test request timeout while trying to log in.""" - with patch('homeassistant.components.cloud.auth_api.login', + with patch('hass_nabucasa.auth.CognitoAuth.login', side_effect=asyncio.TimeoutError): - req = yield from cloud_client.post('/api/cloud/login', json={ + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -156,12 +160,11 @@ def test_login_view_request_timeout(cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_login_view_invalid_credentials(cloud_client): +async def test_login_view_invalid_credentials(cloud_client): """Test logging in with invalid credentials.""" - with patch('homeassistant.components.cloud.auth_api.login', - side_effect=auth_api.Unauthenticated): - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login', + side_effect=Unauthenticated): + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -169,12 +172,11 @@ def test_login_view_invalid_credentials(cloud_client): assert req.status == 401 -@asyncio.coroutine -def test_login_view_unknown_error(cloud_client): +async def test_login_view_unknown_error(cloud_client): """Test unknown error while logging in.""" - with patch('homeassistant.components.cloud.auth_api.login', - side_effect=auth_api.UnknownError): - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login', + side_effect=UnknownError): + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -182,40 +184,36 @@ def test_login_view_unknown_error(cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_logout_view(hass, cloud_client): +async def test_logout_view(hass, cloud_client): """Test logging out.""" cloud = hass.data['cloud'] = MagicMock() cloud.logout.return_value = mock_coro() - req = yield from cloud_client.post('/api/cloud/logout') + req = await cloud_client.post('/api/cloud/logout') assert req.status == 200 - data = yield from req.json() + data = await req.json() assert data == {'message': 'ok'} assert len(cloud.logout.mock_calls) == 1 -@asyncio.coroutine -def test_logout_view_request_timeout(hass, cloud_client): +async def test_logout_view_request_timeout(hass, cloud_client): """Test timeout while logging out.""" cloud = hass.data['cloud'] = MagicMock() cloud.logout.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/logout') + req = await cloud_client.post('/api/cloud/logout') assert req.status == 502 -@asyncio.coroutine -def test_logout_view_unknown_error(hass, cloud_client): +async def test_logout_view_unknown_error(hass, cloud_client): """Test unknown error while logging out.""" cloud = hass.data['cloud'] = MagicMock() - cloud.logout.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/logout') + cloud.logout.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/logout') assert req.status == 502 -@asyncio.coroutine -def test_register_view(mock_cognito, cloud_client): +async def test_register_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) @@ -226,10 +224,9 @@ def test_register_view(mock_cognito, cloud_client): assert result_pass == 'falcon42' -@asyncio.coroutine -def test_register_view_bad_data(mock_cognito, cloud_client): +async def test_register_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'not_password': 'falcon' }) @@ -237,117 +234,104 @@ def test_register_view_bad_data(mock_cognito, cloud_client): assert len(mock_cognito.logout.mock_calls) == 0 -@asyncio.coroutine -def test_register_view_request_timeout(mock_cognito, cloud_client): +async def test_register_view_request_timeout(mock_cognito, cloud_client): """Test timeout while logging out.""" mock_cognito.register.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) assert req.status == 502 -@asyncio.coroutine -def test_register_view_unknown_error(mock_cognito, cloud_client): +async def test_register_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.register.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/register', json={ + mock_cognito.register.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) assert req.status == 502 -@asyncio.coroutine -def test_forgot_password_view(mock_cognito, cloud_client): +async def test_forgot_password_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 200 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 -@asyncio.coroutine -def test_forgot_password_view_bad_data(mock_cognito, cloud_client): +async def test_forgot_password_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'not_email': 'hello@bla.com', }) assert req.status == 400 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 -@asyncio.coroutine -def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): +async def test_forgot_password_view_request_timeout(mock_cognito, + cloud_client): """Test timeout while logging out.""" mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): +async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + mock_cognito.initiate_forgot_password.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_resend_confirm_view(mock_cognito, cloud_client): +async def test_resend_confirm_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 200 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 -@asyncio.coroutine -def test_resend_confirm_view_bad_data(mock_cognito, cloud_client): +async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'not_email': 'hello@bla.com', }) assert req.status == 400 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 -@asyncio.coroutine -def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client): +async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client): """Test timeout while logging out.""" mock_cognito.client.resend_confirmation_code.side_effect = \ asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): +async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = \ - auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + mock_cognito.client.resend_confirmation_code.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): +async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, + mock_cloud_login): """Test querying the status.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') - hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch.dict( @@ -379,6 +363,9 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'exclude_entities': [], }, 'google_domains': ['light'], + 'remote_domain': None, + 'remote_connected': False, + 'remote_certificate': None, } @@ -397,19 +384,15 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): async def test_websocket_subscription_reconnect( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': dt_util.utcnow().date().isoformat() - }, 'test') client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -425,20 +408,16 @@ async def test_websocket_subscription_reconnect( async def test_websocket_subscription_no_reconnect_if_connected( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and not reconnecting because still expired.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': dt_util.utcnow().date().isoformat() - }, 'test') + hass.data[DOMAIN].iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -454,19 +433,15 @@ async def test_websocket_subscription_no_reconnect_if_connected( async def test_websocket_subscription_no_reconnect_if_expired( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and not reconnecting because still expired.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -482,13 +457,10 @@ async def test_websocket_subscription_no_reconnect_if_expired( async def test_websocket_subscription_fail(hass, hass_ws_client, - aioclient_mock, mock_auth): + aioclient_mock, mock_auth, + mock_cloud_login): """Test querying the status.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=500) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -503,7 +475,7 @@ async def test_websocket_subscription_fail(hass, hass_ws_client, async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): """Test querying the status.""" client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.Cloud.fetch_subscription_info', + with patch('hass_nabucasa.Cloud.fetch_subscription_info', return_value=mock_coro({'return': 'value'})): await client.send_json({ 'id': 5, @@ -516,15 +488,12 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): async def test_websocket_update_preferences(hass, hass_ws_client, - aioclient_mock, setup_api): + aioclient_mock, setup_api, + mock_cloud_login): """Test updating preference.""" assert setup_api[PREF_ENABLE_GOOGLE] assert setup_api[PREF_ENABLE_ALEXA] assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -541,15 +510,14 @@ async def test_websocket_update_preferences(hass, hass_ws_client, assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] -async def test_enabling_webhook(hass, hass_ws_client, setup_api): +async def test_enabling_webhook(hass, hass_ws_client, setup_api, + mock_cloud_login): """Test we call right code to enable webhooks.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' - '.async_create', return_value=mock_coro()) as mock_enable: + with patch( + 'hass_nabucasa.cloudhooks.Cloudhooks.async_create', + return_value=mock_coro() + ) as mock_enable: await client.send_json({ 'id': 5, 'type': 'cloud/cloudhook/create', @@ -562,15 +530,14 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api): assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id' -async def test_disabling_webhook(hass, hass_ws_client, setup_api): +async def test_disabling_webhook(hass, hass_ws_client, setup_api, + mock_cloud_login): """Test we call right code to disable webhooks.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' - '.async_delete', return_value=mock_coro()) as mock_disable: + with patch( + 'hass_nabucasa.cloudhooks.Cloudhooks.async_delete', + return_value=mock_coro() + ) as mock_disable: await client.send_json({ 'id': 5, 'type': 'cloud/cloudhook/delete', @@ -581,3 +548,143 @@ async def test_disabling_webhook(hass, hass_ws_client, setup_api): assert len(mock_disable.mock_calls) == 1 assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id' + + +async def test_enabling_remote(hass, hass_ws_client, setup_api, + mock_cloud_login): + """Test we call right code to enable remote UI.""" + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + return_value=mock_coro() + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + assert response['success'] + assert cloud.client.remote_autostart + + assert len(mock_connect.mock_calls) == 1 + + +async def test_disabling_remote(hass, hass_ws_client, setup_api, + mock_cloud_login): + """Test we call right code to disable remote UI.""" + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.disconnect', + return_value=mock_coro() + ) as mock_disconnect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/disconnect', + }) + response = await client.receive_json() + assert response['success'] + assert not cloud.client.remote_autostart + + assert len(mock_disconnect.mock_calls) == 1 + + +async def test_enabling_remote_trusted_networks_local4( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '127.0.0.1' + ] + }) + ) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_networks_local6( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '::1' + ] + }) + ) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_networks_other( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '192.168.0.0/24' + ] + }) + ) + + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + return_value=mock_coro() + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert response['success'] + assert cloud.client.remote_autostart + + assert len(mock_connect.mock_calls) == 1 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index baf6747aead..0de395c8bbc 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -1,71 +1,31 @@ """Test the cloud component.""" -import asyncio -import json -from unittest.mock import patch, MagicMock, mock_open - -import pytest +from unittest.mock import patch +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import cloud -from homeassistant.util.dt import utcnow - +from homeassistant.components.cloud.const import DOMAIN +from homeassistant.components.cloud.prefs import STORAGE_KEY +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.setup import async_setup_component from tests.common import mock_coro -@pytest.fixture -def mock_os(): - """Mock os module.""" - with patch('homeassistant.components.cloud.os') as os: - os.path.isdir.return_value = True - yield os - - -@asyncio.coroutine -def test_constructor_loads_info_from_constant(): +async def test_constructor_loads_info_from_config(hass): """Test non-dev mode loads info from SERVERS constant.""" - hass = MagicMock(data={}) - with patch.dict(cloud.SERVERS, { - 'beer': { - 'cognito_client_id': 'test-cognito_client_id', - 'user_pool_id': 'test-user_pool_id', - 'region': 'test-region', - 'relayer': 'test-relayer', - 'google_actions_sync_url': 'test-google_actions_sync_url', - 'subscription_info_url': 'test-subscription-info-url', - 'cloudhook_create_url': 'test-cloudhook_create_url', - } - }): - result = yield from cloud.async_setup(hass, { - 'cloud': {cloud.CONF_MODE: 'beer'} + with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } }) assert result - cl = hass.data['cloud'] - assert cl.mode == 'beer' - assert cl.cognito_client_id == 'test-cognito_client_id' - assert cl.user_pool_id == 'test-user_pool_id' - assert cl.region == 'test-region' - assert cl.relayer == 'test-relayer' - assert cl.google_actions_sync_url == 'test-google_actions_sync_url' - assert cl.subscription_info_url == 'test-subscription-info-url' - assert cl.cloudhook_create_url == 'test-cloudhook_create_url' - - -@asyncio.coroutine -def test_constructor_loads_info_from_config(): - """Test non-dev mode loads info from SERVERS constant.""" - hass = MagicMock(data={}) - - result = yield from cloud.async_setup(hass, { - 'cloud': { - cloud.CONF_MODE: cloud.MODE_DEV, - 'cognito_client_id': 'test-cognito_client_id', - 'user_pool_id': 'test-user_pool_id', - 'region': 'test-region', - 'relayer': 'test-relayer', - } - }) - assert result - cl = hass.data['cloud'] assert cl.mode == cloud.MODE_DEV assert cl.cognito_client_id == 'test-cognito_client_id' @@ -74,104 +34,99 @@ def test_constructor_loads_info_from_config(): assert cl.relayer == 'test-relayer' -async def test_initialize_loads_info(mock_os, hass): - """Test initialize will load info from config file.""" - mock_os.path.isfile.return_value = True - mopen = mock_open(read_data=json.dumps({ - 'id_token': 'test-id-token', - 'access_token': 'test-access-token', - 'refresh_token': 'test-refresh-token', - })) +async def test_remote_services(hass, mock_cloud_fixture): + """Setup cloud component and test services.""" + cloud = hass.data[DOMAIN] - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - cl.iot = MagicMock() - cl.iot.connect.return_value = mock_coro() + assert hass.services.has_service(DOMAIN, 'remote_connect') + assert hass.services.has_service(DOMAIN, 'remote_disconnect') - with patch('homeassistant.components.cloud.open', mopen, create=True), \ - patch('homeassistant.components.cloud.Cloud._decode_claims'): - await cl.async_start(None) + with patch( + "hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro() + ) as mock_connect: + await hass.services.async_call(DOMAIN, "remote_connect", blocking=True) - assert cl.id_token == 'test-id-token' - assert cl.access_token == 'test-access-token' - assert cl.refresh_token == 'test-refresh-token' - assert len(cl.iot.connect.mock_calls) == 1 + assert mock_connect.called + assert cloud.client.remote_autostart + + with patch( + "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro() + ) as mock_disconnect: + await hass.services.async_call( + DOMAIN, "remote_disconnect", blocking=True) + + assert mock_disconnect.called + assert not cloud.client.remote_autostart -@asyncio.coroutine -def test_logout_clears_info(mock_os, hass): - """Test logging out disconnects and removes info.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - cl.iot = MagicMock() - cl.iot.disconnect.return_value = mock_coro() +async def test_startup_shutdown_events(hass, mock_cloud_fixture): + """Test if the cloud will start on startup event.""" + with patch( + "hass_nabucasa.Cloud.start", return_value=mock_coro() + ) as mock_start: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - yield from cl.logout() + assert mock_start.called - assert len(cl.iot.disconnect.mock_calls) == 1 - assert cl.id_token is None - assert cl.access_token is None - assert cl.refresh_token is None - assert len(mock_os.remove.mock_calls) == 1 + with patch( + "hass_nabucasa.Cloud.stop", return_value=mock_coro() + ) as mock_stop: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert mock_stop.called -@asyncio.coroutine -def test_write_user_info(): - """Test writing user info works.""" - mopen = mock_open() - - cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None) - cl.id_token = 'test-id-token' - cl.access_token = 'test-access-token' - cl.refresh_token = 'test-refresh-token' - - with patch('homeassistant.components.cloud.open', mopen, create=True): - cl.write_user_info() - - handle = mopen() - - assert len(handle.write.mock_calls) == 1 - data = json.loads(handle.write.mock_calls[0][1][0]) - assert data == { - 'access_token': 'test-access-token', - 'id_token': 'test-id-token', - 'refresh_token': 'test-refresh-token', +async def test_setup_existing_cloud_user(hass, hass_storage): + """Test setup with API push default data.""" + user = await hass.auth.async_create_system_user('Cloud test') + hass_storage[STORAGE_KEY] = { + 'version': 1, + 'data': { + 'cloud_user': user.id + } } + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) + assert result + + assert hass_storage[STORAGE_KEY]['data']['cloud_user'] == user.id -@asyncio.coroutine -def test_subscription_expired(hass): - """Test subscription being expired after 3 days of expiration.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - token_val = { - 'custom:sub-exp': '2017-11-13' +async def test_setup_setup_cloud_user(hass, hass_storage): + """Test setup with API push default data.""" + hass_storage[STORAGE_KEY] = { + 'version': 1, + 'data': { + 'cloud_user': None + } } - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2017, month=11, day=13)): - assert not cl.subscription_expired + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) + assert result - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace( - year=2017, month=11, day=19, hour=23, minute=59, - second=59)): - assert not cl.subscription_expired + cloud_user = await hass.auth.async_get_user( + hass_storage[STORAGE_KEY]['data']['cloud_user'] + ) - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace( - year=2017, month=11, day=20, hour=0, minute=0, - second=0)): - assert cl.subscription_expired - - -@asyncio.coroutine -def test_subscription_not_expired(hass): - """Test subscription not being expired.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - token_val = { - 'custom:sub-exp': '2017-11-13' - } - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2017, month=11, day=9)): - assert not cl.subscription_expired + assert cloud_user + assert cloud_user.groups[0].id == GROUP_ID_ADMIN diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py deleted file mode 100644 index 10a94f46833..00000000000 --- a/tests/components/cloud/test_iot.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Test the cloud.iot module.""" -import asyncio -from unittest.mock import patch, MagicMock, PropertyMock - -from aiohttp import WSMsgType, client_exceptions, web -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import ( - Cloud, iot, auth_api, MODE_DEV) -from homeassistant.components.cloud.const import ( - PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) -from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro - -from . import mock_cloud_prefs - - -@pytest.fixture -def mock_client(): - """Mock the IoT client.""" - client = MagicMock() - type(client).closed = PropertyMock(side_effect=[False, True]) - - # Trigger cancelled error to avoid reconnect. - with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \ - patch('homeassistant.components.cloud.iot' - '.async_get_clientsession') as session: - session().ws_connect.return_value = mock_coro(client) - yield client - - -@pytest.fixture -def mock_handle_message(): - """Mock handle message.""" - with patch('homeassistant.components.cloud.iot' - '.async_handle_message') as mock: - yield mock - - -@pytest.fixture -def mock_cloud(): - """Mock cloud class.""" - return MagicMock(subscription_expired=False) - - -@asyncio.coroutine -def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): - """Test we call handle message with correct info.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'test-handler', - 'payload': 'test-payload' - }) - )) - mock_handle_message.return_value = mock_coro('response') - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent message to handler correctly - assert len(mock_handle_message.mock_calls) == 1 - p_hass, p_cloud, handler_name, payload = \ - mock_handle_message.mock_calls[0][1] - - assert p_hass is mock_cloud.hass - assert p_cloud is mock_cloud - assert handler_name == 'test-handler' - assert payload == 'test-payload' - - # Check that we forwarded response from handler to cloud - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'payload': 'response' - } - - -@asyncio.coroutine -def test_connection_msg_for_unknown_handler(mock_client, mock_cloud): - """Test a msg for an unknown handler.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'non-existing-handler', - 'payload': 'test-payload' - }) - )) - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent the correct error - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'error': 'unknown-handler', - } - - -@asyncio.coroutine -def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, - mock_cloud): - """Test we sent error when handler raises exception.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'test-handler', - 'payload': 'test-payload' - }) - )) - mock_handle_message.side_effect = Exception('Broken') - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent the correct error - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'error': 'exception', - } - - -@asyncio.coroutine -def test_handler_forwarding(): - """Test we forward messages to correct handler.""" - handler = MagicMock() - handler.return_value = mock_coro() - hass = object() - cloud = object() - with patch.dict(iot.HANDLERS, {'test': handler}): - yield from iot.async_handle_message( - hass, cloud, 'test', 'payload') - - assert len(handler.mock_calls) == 1 - r_hass, r_cloud, payload = handler.mock_calls[0][1] - assert r_hass is hass - assert r_cloud is cloud - assert payload == 'payload' - - -async def test_handling_core_messages_logout(hass, mock_cloud): - """Test handling core messages.""" - mock_cloud.logout.return_value = mock_coro() - await iot.async_handle_cloud(hass, mock_cloud, { - 'action': 'logout', - 'reason': 'Logged in at two places.' - }) - assert len(mock_cloud.logout.mock_calls) == 1 - - -@asyncio.coroutine -def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): - """Test server disconnecting instance.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.CLOSING, - )) - - with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]): - yield from conn.connect() - - assert 'Connection closed' in caplog.text - - -@asyncio.coroutine -def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): - """Test server disconnecting instance.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.BINARY, - )) - - yield from conn.connect() - - assert 'Connection closed: Received non-Text message' in caplog.text - - -@asyncio.coroutine -def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): - """Test cloud sending invalid JSON.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.TEXT, - json=MagicMock(side_effect=ValueError) - )) - - yield from conn.connect() - - assert 'Connection closed: Received invalid JSON.' in caplog.text - - -@asyncio.coroutine -def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): - """Test cloud unable to check token.""" - conn = iot.CloudIoT(mock_cloud) - mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA") - - yield from conn.connect() - - assert 'Unable to refresh token: BLA' in caplog.text - - -@asyncio.coroutine -def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): - """Test invalid auth detected by server.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = \ - client_exceptions.WSServerHandshakeError(None, None, status=401) - - yield from conn.connect() - - assert 'Connection closed: Invalid auth.' in caplog.text - - -@asyncio.coroutine -def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud): - """Test unable to connect error.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = client_exceptions.ClientError(None, None) - - yield from conn.connect() - - assert 'Unable to connect:' in caplog.text - - -@asyncio.coroutine -def test_cloud_random_exception(mock_client, caplog, mock_cloud): - """Test random exception.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = Exception - - yield from conn.connect() - - assert 'Unexpected error' in caplog.text - - -@asyncio.coroutine -def test_refresh_token_before_expiration_fails(hass, mock_cloud): - """Test that we don't connect if token is expired.""" - mock_cloud.subscription_expired = True - mock_cloud.hass = hass - conn = iot.CloudIoT(mock_cloud) - - with patch('homeassistant.components.cloud.auth_api.check_token', - return_value=mock_coro()) as mock_check_token, \ - patch.object(hass.components.persistent_notification, - 'async_create') as mock_create: - yield from conn.connect() - - assert len(mock_check_token.mock_calls) == 1 - assert len(mock_create.mock_calls) == 1 - - -@asyncio.coroutine -def test_handler_alexa(hass): - """Test handler Alexa.""" - hass.states.async_set( - 'switch.test', 'on', {'friendly_name': "Test switch"}) - hass.states.async_set( - 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - setup = yield from async_setup_component(hass, 'cloud', { - 'cloud': { - 'alexa': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'description': 'Config description', - 'display_categories': 'LIGHT' - } - } - } - } - }) - assert setup - - mock_cloud_prefs(hass) - - resp = yield from iot.async_handle_alexa( - hass, hass.data['cloud'], - test_alexa.get_new_request('Alexa.Discovery', 'Discover')) - - endpoints = resp['event']['payload']['endpoints'] - - assert len(endpoints) == 1 - device = endpoints[0] - - assert device['description'] == 'Config description' - assert device['friendlyName'] == 'Config name' - assert device['displayCategories'] == ['LIGHT'] - assert device['manufacturerName'] == 'Home Assistant' - - -@asyncio.coroutine -def test_handler_alexa_disabled(hass, mock_cloud_fixture): - """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_ALEXA] = False - - resp = yield from iot.async_handle_alexa( - hass, hass.data['cloud'], - test_alexa.get_new_request('Alexa.Discovery', 'Discover')) - - assert resp['event']['header']['namespace'] == 'Alexa' - assert resp['event']['header']['name'] == 'ErrorResponse' - assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' - - -@asyncio.coroutine -def test_handler_google_actions(hass): - """Test handler Google Actions.""" - hass.states.async_set( - 'switch.test', 'on', {'friendly_name': "Test switch"}) - hass.states.async_set( - 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - hass.states.async_set( - 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - setup = yield from async_setup_component(hass, 'cloud', { - 'cloud': { - 'google_actions': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'aliases': 'Config alias', - 'room': 'living room' - } - } - } - } - }) - assert setup - - mock_cloud_prefs(hass) - - reqid = '5711642932632160983' - data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} - - with patch('homeassistant.components.cloud.Cloud._decode_claims', - return_value={'cognito:username': 'myUserName'}): - resp = yield from iot.async_handle_google_actions( - hass, hass.data['cloud'], data) - - assert resp['requestId'] == reqid - payload = resp['payload'] - - assert payload['agentUserId'] == 'myUserName' - - devices = payload['devices'] - assert len(devices) == 1 - - device = devices[0] - assert device['id'] == 'switch.test' - assert device['name']['name'] == 'Config name' - assert device['name']['nicknames'] == ['Config alias'] - assert device['type'] == 'action.devices.types.SWITCH' - assert device['roomHint'] == 'living room' - - -async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): - """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - assert await async_setup_component(hass, 'cloud', {}) - - reqid = '5711642932632160983' - data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} - - resp = await iot.async_handle_google_actions( - hass, hass.data['cloud'], data) - - assert resp['requestId'] == reqid - assert resp['payload']['errorCode'] == 'deviceTurnedOff' - - -async def test_refresh_token_expired(hass): - """Test handling Unauthenticated error raised if refresh token expired.""" - cloud = Cloud(hass, MODE_DEV, None, None) - - with patch('homeassistant.components.cloud.auth_api.check_token', - side_effect=auth_api.Unauthenticated) as mock_check_token, \ - patch.object(hass.components.persistent_notification, - 'async_create') as mock_create: - await cloud.iot.connect() - - assert len(mock_check_token.mock_calls) == 1 - assert len(mock_create.mock_calls) == 1 - - -async def test_webhook_msg(hass): - """Test webhook msg.""" - cloud = Cloud(hass, MODE_DEV, None, None) - await cloud.prefs.async_initialize() - await cloud.prefs.async_update(cloudhooks={ - 'hello': { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id' - } - }) - - received = [] - - async def handler(hass, webhook_id, request): - """Handle a webhook.""" - received.append(request) - return web.json_response({'from': 'handler'}) - - hass.components.webhook.async_register( - 'test', 'Test', 'mock-webhook-id', handler) - - response = await iot.async_handle_webhook(hass, cloud, { - 'cloudhook_id': 'mock-cloud-id', - 'body': '{"hello": "world"}', - 'headers': { - 'content-type': 'application/json' - }, - 'method': 'POST', - 'query': None, - }) - - assert response == { - 'status': 200, - 'body': '{"from": "handler"}', - 'headers': { - 'Content-Type': 'application/json' - } - } - - assert len(received) == 1 - assert await received[0].json() == { - 'hello': 'world' - } - - -async def test_send_message_not_connected(mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - - with pytest.raises(iot.NotConnected): - await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) - - -async def test_send_message_no_answer(mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - cloud_iot.state = iot.STATE_CONNECTED - cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) - - await cloud_iot.async_send_message('webhook', {'msg': 'yo'}, - expect_answer=False) - assert not cloud_iot._response_handler - assert len(cloud_iot.client.send_json.mock_calls) == 1 - msg = cloud_iot.client.send_json.mock_calls[0][1][0] - assert msg['handler'] == 'webhook' - assert msg['payload'] == {'msg': 'yo'} - - -async def test_send_message_answer(loop, mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - cloud_iot.state = iot.STATE_CONNECTED - cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) - - uuid = 5 - - with patch('homeassistant.components.cloud.iot.uuid.uuid4', - return_value=MagicMock(hex=uuid)): - send_task = loop.create_task(cloud_iot.async_send_message( - 'webhook', {'msg': 'yo'})) - await asyncio.sleep(0) - - assert len(cloud_iot.client.send_json.mock_calls) == 1 - assert len(cloud_iot._response_handler) == 1 - msg = cloud_iot.client.send_json.mock_calls[0][1][0] - assert msg['handler'] == 'webhook' - assert msg['payload'] == {'msg': 'yo'} - - cloud_iot._response_handler[uuid].set_result({'response': True}) - response = await send_task - assert response == {'response': True} diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 5cc7b4bd82e..316740488e3 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -112,7 +112,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): async def test_delete_requires_admin(hass, hass_ws_client, hass_read_only_access_token): - """Test delete command requires an owner.""" + """Test delete command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) await client.send_json({ @@ -205,7 +205,7 @@ async def test_create(hass, hass_ws_client, hass_access_token): async def test_create_requires_admin(hass, hass_ws_client, hass_read_only_access_token): - """Test create command requires an owner.""" + """Test create command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) await client.send_json({ @@ -217,3 +217,67 @@ async def test_create_requires_admin(hass, hass_ws_client, result = await client.receive_json() assert not result['success'], result assert result['error']['code'] == 'unauthorized' + + +async def test_update(hass, hass_ws_client): + """Test update command works.""" + client = await hass_ws_client(hass) + + user = await hass.auth.async_create_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + 'group_ids': ['system-read-only'], + }) + + result = await client.receive_json() + assert result['success'], result + data_user = result['result']['user'] + + assert user.name == "Updated name" + assert data_user['name'] == "Updated name" + assert len(user.groups) == 1 + assert user.groups[0].id == "system-read-only" + assert data_user['group_ids'] == ["system-read-only"] + + +async def test_update_requires_admin(hass, hass_ws_client, + hass_read_only_access_token): + """Test update command requires an admin.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + + user = await hass.auth.async_create_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + assert user.name == "Test user" + + +async def test_update_system_generated(hass, hass_ws_client): + """Test update command cannot update a system generated.""" + client = await hass_ws_client(hass) + + user = await hass.auth.async_create_system_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'cannot_modify_system_generated' + assert user.name == "Test user" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 87ed83d9a7e..852a5adf6a2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -226,6 +226,7 @@ def test_abort(hass, client): data = yield from resp.json() data.pop('flow_id') assert data == { + 'description_placeholders': None, 'handler': 'test', 'reason': 'bla', 'type': 'abort' @@ -254,6 +255,10 @@ def test_create_account(hass, client): json={'handler': 'test'}) assert resp.status == 200 + + entries = hass.config_entries.async_entries('test') + assert len(entries) == 1 + data = yield from resp.json() data.pop('flow_id') assert data == { @@ -261,6 +266,7 @@ def test_create_account(hass, client): 'title': 'Test Entry', 'type': 'create_entry', 'version': 1, + 'result': entries[0].entry_id, 'description': None, 'description_placeholders': None, } @@ -316,6 +322,10 @@ def test_two_step_flow(hass, client): '/api/config/config_entries/flow/{}'.format(flow_id), json={'user_title': 'user-title'}) assert resp.status == 200 + + entries = hass.config_entries.async_entries('test') + assert len(entries) == 1 + data = yield from resp.json() data.pop('flow_id') assert data == { @@ -323,6 +333,7 @@ def test_two_step_flow(hass, client): 'type': 'create_entry', 'title': 'user-title', 'version': 1, + 'result': entries[0].entry_id, 'description': None, 'description_placeholders': None, } diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py deleted file mode 100644 index 547bb612ee4..00000000000 --- a/tests/components/config/test_hassbian.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Test hassbian config.""" -import asyncio -import os -from unittest.mock import patch - -from homeassistant.bootstrap import async_setup_component -from homeassistant.components import config -from homeassistant.components.config.hassbian import ( - HassbianSuitesView, HassbianSuiteInstallView) - - -def test_setup_check_env_prevents_load(hass, loop): - """Test it does not set up hassbian if environment var not present.""" - with patch.dict(os.environ, clear=True), \ - patch.object(config, 'SECTIONS', ['hassbian']), \ - patch('homeassistant.components.http.' - 'HomeAssistantHTTP.register_view') as reg_view: - loop.run_until_complete(async_setup_component(hass, 'config', {})) - assert 'config' in hass.config.components - assert reg_view.called is False - - -def test_setup_check_env_works(hass, loop): - """Test it sets up hassbian if environment var present.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']), \ - patch('homeassistant.components.http.' - 'HomeAssistantHTTP.register_view') as reg_view: - loop.run_until_complete(async_setup_component(hass, 'config', {})) - assert 'config' in hass.config.components - assert len(reg_view.mock_calls) == 2 - assert isinstance(reg_view.mock_calls[0][1][0], HassbianSuitesView) - assert isinstance(reg_view.mock_calls[1][1][0], HassbianSuiteInstallView) - - -@asyncio.coroutine -def test_get_suites(hass, hass_client): - """Test getting suites.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from hass_client() - resp = yield from client.get('/api/config/hassbian/suites') - assert resp.status == 200 - result = yield from resp.json() - - assert 'mosquitto' in result - info = result['mosquitto'] - assert info['state'] == 'failed' - assert info['description'] == \ - 'Installs the Mosquitto package for setting up a local MQTT server' - - -@asyncio.coroutine -def test_install_suite(hass, hass_client): - """Test getting suites.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from hass_client() - resp = yield from client.post( - '/api/config/hassbian/suites/openzwave/install') - assert resp.status == 200 - result = yield from resp.json() - - assert result == {"status": "ok"} diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index b3ce1e93e4c..7a791b334aa 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -1,13 +1,13 @@ """The tests for the Unifi WAP device tracker platform.""" from unittest import mock +from datetime import datetime, timedelta from pyunifi.controller import APIError -import homeassistant.util.dt as dt_util -from datetime import timedelta import pytest import voluptuous as vol +import homeassistant.util.dt as dt_util from homeassistant.components.device_tracker import DOMAIN, unifi as unifi from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL, @@ -241,7 +241,8 @@ def test_monitored_conditions(): 'hostname': 'foobar', 'essid': 'barnet', 'signal': -60, - 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + 'last_seen': dt_util.as_timestamp(dt_util.utcnow()), + 'latest_assoc_time': 946684800.0}, {'mac': '234', 'name': 'Nice Name', 'essid': 'barnet', @@ -254,9 +255,14 @@ def test_monitored_conditions(): ] ctrl.get_clients.return_value = fake_clients scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, - ['essid', 'signal']) - assert scanner.get_extra_attributes('123') == {'essid': 'barnet', - 'signal': -60} - assert scanner.get_extra_attributes('234') == {'essid': 'barnet', - 'signal': -42} + ['essid', 'signal', 'latest_assoc_time']) + assert scanner.get_extra_attributes('123') == { + 'essid': 'barnet', + 'signal': -60, + 'latest_assoc_time': datetime(2000, 1, 1, 0, 0, tzinfo=dt_util.UTC) + } + assert scanner.get_extra_attributes('234') == { + 'essid': 'barnet', + 'signal': -42 + } assert scanner.get_extra_attributes('456') == {'essid': 'barnet'} diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index d4566bc0b03..28d30a9167f 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -1,6 +1,5 @@ """The tests for the discovery component.""" import asyncio -import os from unittest.mock import patch, MagicMock import pytest @@ -142,21 +141,6 @@ def test_discover_duplicates(hass): SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG) -@asyncio.coroutine -def test_load_component_hassio(hass): - """Test load hassio component.""" - def discover(netdisco): - """Fake discovery.""" - return [] - - with patch.dict(os.environ, {'HASSIO': "FAKE_HASSIO"}), \ - patch('homeassistant.components.hassio.async_setup', - return_value=mock_coro(return_value=True)) as mock_hassio: - yield from mock_discovery(hass, discover) - - assert mock_hassio.called - - async def test_discover_config_flow(hass): """Test discovery triggering a config flow.""" discovery_info = { diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 5e3d6d1019c..8be99a02148 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -11,7 +11,7 @@ from tests.common import get_test_instance_port from homeassistant import core, const, setup import homeassistant.components as core_components from homeassistant.components import ( - fan, http, light, script, emulated_hue, media_player, cover) + fan, http, light, script, emulated_hue, media_player, cover, climate) from homeassistant.components.emulated_hue import Config from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, @@ -77,6 +77,15 @@ def hass_hue(loop, hass): } })) + loop.run_until_complete( + setup.async_setup_component(hass, climate.DOMAIN, { + 'climate': [ + { + 'platform': 'demo', + } + ] + })) + loop.run_until_complete( setup.async_setup_component(hass, media_player.DOMAIN, { 'media_player': [ @@ -136,6 +145,22 @@ def hass_hue(loop, hass): cover_entity.entity_id, cover_entity.state, attributes=attrs ) + # Expose Hvac + hvac_entity = hass.states.get('climate.hvac') + attrs = dict(hvac_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass.states.async_set( + hvac_entity.entity_id, hvac_entity.state, attributes=attrs + ) + + # Expose HeatPump + hp_entity = hass.states.get('climate.heatpump') + attrs = dict(hp_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass.states.async_set( + hp_entity.entity_id, hp_entity.state, attributes=attrs + ) + return hass @@ -189,6 +214,9 @@ def test_discover_lights(hue_client): assert 'fan.living_room_fan' in devices assert 'fan.ceiling_fan' not in devices assert 'cover.living_room_window' in devices + assert 'climate.hvac' in devices + assert 'climate.heatpump' in devices + assert 'climate.ecobee' not in devices @asyncio.coroutine @@ -316,6 +344,84 @@ def test_put_light_state_script(hass_hue, hue_client): assert kitchen_light.attributes[light.ATTR_BRIGHTNESS] == level +@asyncio.coroutine +def test_put_light_state_climate_set_temperature(hass_hue, hue_client): + """Test setting climate temperature.""" + brightness = 19 + temperature = round(brightness / 255 * 100) + + hvac_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.hvac', True, brightness) + + hvac_result_json = yield from hvac_result.json() + + assert hvac_result.status == 200 + assert len(hvac_result_json) == 2 + + hvac = hass_hue.states.get('climate.hvac') + assert hvac.state == climate.const.STATE_COOL + assert hvac.attributes[climate.ATTR_TEMPERATURE] == temperature + assert hvac.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_COOL + + # Make sure we can't change the ecobee temperature since it's not exposed + ecobee_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.ecobee', True) + assert ecobee_result.status == 404 + + +@asyncio.coroutine +def test_put_light_state_climate_turn_on(hass_hue, hue_client): + """Test inability to turn climate on.""" + yield from hass_hue.services.async_call( + climate.DOMAIN, const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: 'climate.heatpump'}, + blocking=True) + + # Somehow after calling the above service the device gets unexposed, + # so we need to expose it again + hp_entity = hass_hue.states.get('climate.heatpump') + attrs = dict(hp_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass_hue.states.async_set( + hp_entity.entity_id, hp_entity.state, attributes=attrs + ) + + hp_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.heatpump', True) + + hp_result_json = yield from hp_result.json() + + assert hp_result.status == 200 + assert len(hp_result_json) == 1 + + hp = hass_hue.states.get('climate.heatpump') + assert hp.state == STATE_OFF + assert hp.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_HEAT + + +@asyncio.coroutine +def test_put_light_state_climate_turn_off(hass_hue, hue_client): + """Test inability to turn climate off.""" + hp_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.heatpump', False) + + hp_result_json = yield from hp_result.json() + + assert hp_result.status == 200 + assert len(hp_result_json) == 1 + + hp = hass_hue.states.get('climate.heatpump') + assert hp.state == climate.const.STATE_HEAT + assert hp.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_HEAT + + @asyncio.coroutine def test_put_light_state_media_player(hass_hue, hue_client): """Test turning on media player and setting volume.""" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index d1ec80844b6..302e8d8674f 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,5 +1,7 @@ """Test Google Smart Home.""" -from homeassistant.core import State +import pytest + +from homeassistant.core import State, EVENT_CALL_SERVICE from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from homeassistant.setup import async_setup_component @@ -11,15 +13,28 @@ from homeassistant.components.google_assistant import ( EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED) from homeassistant.components.light.demo import DemoLight +from homeassistant.helpers import device_registry +from tests.common import (mock_device_registry, mock_registry, + mock_area_registry) BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' +@pytest.fixture +def registries(hass): + """Registry mock setup.""" + from types import SimpleNamespace + ret = SimpleNamespace() + ret.entity = mock_registry(hass) + ret.device = mock_device_registry(hass) + ret.area = mock_area_registry(hass) + return ret + + async def test_sync_message(hass): """Test a sync message.""" light = DemoLight( @@ -40,7 +55,6 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', allow_unlock=False, - agent_user_id='test-agent', entity_config={ 'light.demo_light': { const.CONF_ROOM_HINT: 'Living Room', @@ -52,12 +66,14 @@ async def test_sync_message(hass): events = [] hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) - result = await sh.async_handle_message(hass, config, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -98,6 +114,85 @@ async def test_sync_message(hass): } +# pylint: disable=redefined-outer-name +async def test_sync_in_area(hass, registries): + """Test a sync message where room hint comes from area.""" + area = registries.area.async_create("Living Room") + + device = registries.device.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + registries.device.async_update_device(device.id, area_id=area.id) + + entity = registries.entity.async_get_or_create( + 'light', 'test', '1235', + suggested_object_id='demo_light', + device_id=device.id) + + light = DemoLight( + None, 'Demo Light', + state=False, + hs_color=(180, 75), + ) + light.hass = hass + light.entity_id = entity.entity_id + await light.async_update_ha_state() + + config = helpers.Config( + should_expose=lambda _: True, + allow_unlock=False, + entity_config={} + ) + + events = [] + hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) + + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'id': 'light.demo_light', + 'name': { + 'name': 'Demo Light' + }, + 'traits': [ + trait.TRAIT_BRIGHTNESS, + trait.TRAIT_ONOFF, + trait.TRAIT_COLOR_SPECTRUM, + trait.TRAIT_COLOR_TEMP, + ], + 'type': sh.TYPE_LIGHT, + 'willReportState': False, + 'attributes': { + 'colorModel': 'rgb', + 'temperatureMinK': 2000, + 'temperatureMaxK': 6535, + }, + 'roomHint': 'Living Room' + }] + } + } + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == EVENT_SYNC_RECEIVED + assert events[0].data == { + 'request_id': REQ_ID, + } + + async def test_query_message(hass): """Test a sync message.""" light = DemoLight( @@ -123,21 +218,23 @@ async def test_query_message(hass): events = [] hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.QUERY", - "payload": { - "devices": [{ - "id": "light.demo_light", - }, { - "id": "light.another_light", - }, { - "id": "light.non_existing", - }] - } - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) assert result == { 'requestId': REQ_ID, @@ -187,39 +284,44 @@ async def test_execute(hass): 'light': {'platform': 'demo'} }) - events = [] - hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) - await hass.services.async_call( 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, blocking=True) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "light.non_existing"}, - {"id": "light.ceiling_lights"}, - ], - "execution": [{ - "command": "action.devices.commands.OnOff", - "params": { - "on": True - } - }, { - "command": - "action.devices.commands.BrightnessAbsolute", - "params": { - "brightness": 20 - } + events = [] + hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) + + service_events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append) + + result = await sh.async_handle_message( + hass, BASIC_CONFIG, None, + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -290,6 +392,24 @@ async def test_execute(hass): } } + assert len(service_events) == 2 + assert service_events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'entity_id': 'light.ceiling_lights'} + } + assert service_events[0].context == events[2].context + assert service_events[1].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': { + 'brightness_pct': 20, + 'entity_id': 'light.ceiling_lights' + } + } + assert service_events[1].context == events[2].context + assert service_events[1].context == events[3].context + async def test_raising_error_trait(hass): """Test raising an error while executing a trait command.""" @@ -304,26 +424,28 @@ async def test_raising_error_trait(hass): hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) await hass.async_block_till_done() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "climate.bla"}, - ], - "execution": [{ - "command": "action.devices.commands." - "ThermostatTemperatureSetpoint", - "params": { - "thermostatTemperatureSetpoint": 10 - } + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -350,11 +472,13 @@ async def test_raising_error_trait(hass): } -def test_serialize_input_boolean(): +async def test_serialize_input_boolean(hass): """Test serializing an input boolean entity.""" state = State('input_boolean.bla', 'on') - entity = sh._GoogleEntity(None, BASIC_CONFIG, state) - assert entity.sync_serialize() == { + # pylint: disable=protected-access + entity = sh._GoogleEntity(hass, BASIC_CONFIG, state) + result = await entity.sync_serialize() + assert result == { 'id': 'input_boolean.bla', 'attributes': {}, 'name': {'name': 'bla'}, @@ -372,15 +496,17 @@ async def test_unavailable_state_doesnt_sync(hass): ) light.hass = hass light.entity_id = 'light.demo_light' - light._available = False + light._available = False # pylint: disable=protected-access await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -401,12 +527,14 @@ async def test_empty_name_doesnt_sync(hass): light.entity_id = 'light.demo_light' await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -419,11 +547,13 @@ async def test_empty_name_doesnt_sync(hass): async def test_query_disconnect(hass): """Test a disconnect message.""" - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - 'inputs': [ - {'intent': 'action.devices.DISCONNECT'} - ], - 'requestId': REQ_ID - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + 'inputs': [ + {'intent': 'action.devices.DISCONNECT'} + ], + 'requestId': REQ_ID + }) assert result is None diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e051a5de4da..301de9c8c25 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -19,19 +19,25 @@ from homeassistant.components.google_assistant import trait, helpers, const from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE) -from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE from homeassistant.util import color from tests.common import async_mock_service BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False +) + +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + +BASIC_DATA = helpers.RequestData( + BASIC_CONFIG, + 'test-agent', + REQ_ID, ) UNSAFE_CONFIG = helpers.Config( should_expose=lambda state: True, - agent_user_id='test-agent', allow_unlock=True, ) @@ -51,16 +57,28 @@ async def test_brightness_light(hass): 'brightness': 95 } + events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) + await hass.async_block_till_done() + assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', light.ATTR_BRIGHTNESS_PCT: 50 } + assert len(events) == 1 + assert events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'brightness_pct': 50, 'entity_id': 'light.bla'} + } + async def test_brightness_cover(hass): """Test brightness trait support for cover domain.""" @@ -79,9 +97,9 @@ async def test_brightness_cover(hass): calls = async_mock_service( hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -107,9 +125,9 @@ async def test_brightness_media_player(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 60 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 60}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -137,18 +155,18 @@ async def test_onoff_group(hass): } on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', } off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -176,9 +194,9 @@ async def test_onoff_input_boolean(hass): } on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -186,9 +204,9 @@ async def test_onoff_input_boolean(hass): off_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -216,18 +234,18 @@ async def test_onoff_switch(hass): } on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', } off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -252,18 +270,18 @@ async def test_onoff_fan(hass): } on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', } off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -290,18 +308,18 @@ async def test_onoff_light(hass): } on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', } off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -329,9 +347,9 @@ async def test_onoff_cover(hass): } on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -339,9 +357,9 @@ async def test_onoff_cover(hass): off_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -369,9 +387,9 @@ async def test_onoff_media_player(hass): } on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -380,9 +398,9 @@ async def test_onoff_media_player(hass): off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -410,9 +428,9 @@ async def test_onoff_climate(hass): } on_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -421,9 +439,9 @@ async def test_onoff_climate(hass): off_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -445,7 +463,8 @@ async def test_dock_vacuum(hass): calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) - await trt.execute(trait.COMMAND_DOCK, {}) + await trt.execute( + trait.COMMAND_DOCK, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -469,7 +488,7 @@ async def test_startstop_vacuum(hass): start_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': True}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}) assert len(start_calls) == 1 assert start_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -477,7 +496,7 @@ async def test_startstop_vacuum(hass): stop_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_STOP) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': False}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}) assert len(stop_calls) == 1 assert stop_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -485,7 +504,7 @@ async def test_startstop_vacuum(hass): pause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_PAUSE) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': True}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}) assert len(pause_calls) == 1 assert pause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -493,7 +512,7 @@ async def test_startstop_vacuum(hass): unpause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': False}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}) assert len(unpause_calls) == 1 assert unpause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -532,7 +551,7 @@ async def test_color_spectrum_light(hass): }) calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'spectrumRGB': 1052927 } @@ -581,14 +600,14 @@ async def test_color_temperature_light(hass): calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 5555 } }) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 2857 } @@ -626,7 +645,7 @@ async def test_scene_scene(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'scene.bla', @@ -643,7 +662,7 @@ async def test_scene_script(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) # We don't wait till script execution is done. await hass.async_block_till_done() @@ -695,10 +714,11 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { - 'thermostatTemperatureSetpointHigh': 25, - 'thermostatTemperatureSetpointLow': 20, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -708,7 +728,7 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) - await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, { + await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'heatcool', }) assert len(calls) == 1 @@ -718,9 +738,9 @@ async def test_temperature_setting_climate_range(hass): } with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE hass.config.units.temperature_unit = TEMP_CELSIUS @@ -762,13 +782,13 @@ async def test_temperature_setting_climate_setpoint(hass): hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) with pytest.raises(helpers.SmartHomeError): - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': 19, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': 19}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -793,7 +813,7 @@ async def test_lock_unlock_lock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': True}) assert len(calls) == 1 assert calls[0].data == { @@ -830,7 +850,7 @@ async def test_lock_unlock_unlock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}) assert len(calls) == 1 assert calls[0].data == { @@ -910,7 +930,8 @@ async def test_fan_speed(hass): trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) - await trt.execute(trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) + await trt.execute( + trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}) assert len(calls) == 1 assert calls[0].data == { @@ -995,7 +1016,7 @@ async def test_modes(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) await trt.execute( - trait.COMMAND_MODES, params={ + trait.COMMAND_MODES, BASIC_DATA, { 'updateModeSettings': { trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' }}) diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index db1ae655c25..577da5f33e6 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -1,22 +1,23 @@ """The tests the for GPSLogger device tracker platform.""" -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import pytest -from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant import data_entry_flow -from homeassistant.components import zone, gpslogger -from homeassistant.components.device_tracker import \ - DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components import gpslogger, zone +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN) from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE -from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ - STATE_HOME, STATE_NOT_HOME, CONF_WEBHOOK_ID +from homeassistant.const import ( + HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) +from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 +# pylint: disable=redefined-outer-name + @pytest.fixture(autouse=True) def mock_dev_track(mock_device_tracker_conf): @@ -158,29 +159,39 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): assert req.status == HTTP_OK state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, data['device'])) - assert STATE_NOT_HOME == state.state - assert 10.5 == state.attributes['gps_accuracy'] - assert 10.0 == state.attributes['battery'] - assert 100.0 == state.attributes['speed'] - assert 105.32 == state.attributes['direction'] - assert 102.0 == state.attributes['altitude'] - assert 'gps' == state.attributes['provider'] - assert 'running' == state.attributes['activity'] + assert state.state == STATE_NOT_HOME + assert state.attributes['gps_accuracy'] == 10.5 + assert state.attributes['battery'] == 10.0 + assert state.attributes['speed'] == 100.0 + assert state.attributes['direction'] == 105.32 + assert state.attributes['altitude'] == 102.0 + assert state.attributes['provider'] == 'gps' + assert state.attributes['activity'] == 'running' @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' ) -async def test_load_unload_entry(hass): +async def test_load_unload_entry(hass, gpslogger_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" - entry = MockConfigEntry(domain=DOMAIN, data={ - CONF_WEBHOOK_ID: 'gpslogger_test' - }) + url = '/api/webhook/{}'.format(webhook_id) + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + } - await gpslogger.async_setup_entry(hass, entry) + # Enter the Home + req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() - assert 1 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 - await gpslogger.async_unload_entry(hass, entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await gpslogger.async_unload_entry(hass, entry) await hass.async_block_till_done() - assert 0 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE] diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index db3917a2201..3e7b9e95d92 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -74,17 +74,6 @@ async def test_api_homeassistant_restart(hassio_handler, aioclient_mock): assert aioclient_mock.call_count == 1 -async def test_api_homeassistant_config(hassio_handler, aioclient_mock): - """Test setup with API HomeAssistant config.""" - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={ - 'result': 'ok', 'data': {'test': 'bla'}}) - - data = await hassio_handler.check_homeassistant_config() - assert data['data']['test'] == 'bla' - assert aioclient_mock.call_count == 1 - - async def test_api_addon_info(hassio_handler, aioclient_mock): """Test setup with API Add-on info.""" aioclient_mock.get( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 62e7278ba1f..1326805fc93 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -7,8 +7,7 @@ import pytest from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component -from homeassistant.components.hassio import ( - STORAGE_KEY, async_check_config) +from homeassistant.components.hassio import STORAGE_KEY from tests.common import mock_coro @@ -51,7 +50,6 @@ def test_setup_api_push_api_data(hass, aioclient_mock): with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { - 'api_password': "123456", 'server_port': 9999 }, 'hassio': {} @@ -60,7 +58,6 @@ def test_setup_api_push_api_data(hass, aioclient_mock): assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert aioclient_mock.mock_calls[1][2]['watchdog'] @@ -71,7 +68,6 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { - 'api_password': "123456", 'server_port': 9999, 'server_host': "127.0.0.1" }, @@ -81,7 +77,6 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert not aioclient_mock.mock_calls[1][2]['watchdog'] @@ -98,7 +93,6 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] is None assert aioclient_mock.mock_calls[1][2]['port'] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token'] hassio_user = await hass.auth.async_get_user( @@ -159,7 +153,6 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] is None assert aioclient_mock.mock_calls[1][2]['port'] == 8123 assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token @@ -317,8 +310,6 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) aioclient_mock.post( "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() @@ -328,32 +319,14 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + with patch( + 'homeassistant.config.async_check_ha_config_file', + return_value=mock_coro() + ) as mock_check_config: + yield from hass.services.async_call('homeassistant', 'restart') + yield from hass.async_block_till_done() + assert mock_check_config.called + assert aioclient_mock.call_count == 3 - - yield from hass.services.async_call('homeassistant', 'restart') - yield from hass.async_block_till_done() - - assert aioclient_mock.call_count == 5 - - -@asyncio.coroutine -def test_check_config_ok(hassio_env, hass, aioclient_mock): - """Check Config that is okay.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) - - assert (yield from async_check_config(hass)) is None - - -@asyncio.coroutine -def test_check_config_fail(hassio_env, hass, aioclient_mock): - """Check Config that is wrong.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={ - 'result': 'error', 'message': "Error"}) - - assert (yield from async_check_config(hass)) == "Error" diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index d543cf51749..0447de97929 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -6,7 +6,7 @@ from homekit.model.services import AbstractService, ServicesTypes from homekit.model.characteristics import ( AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes) from homekit.model import Accessory, get_id - +from homekit.exceptions import AccessoryNotFoundError from homeassistant.components.homekit_controller import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, SERVICE_HOMEKIT) from homeassistant.setup import async_setup_component @@ -22,38 +22,51 @@ class FakePairing: class. """ - def __init__(self, accessory): + def __init__(self, accessories): """Create a fake pairing from an accessory model.""" - self.accessory = accessory - self.pairing_data = { - 'accessories': self.list_accessories_and_characteristics() - } + self.accessories = accessories + self.pairing_data = {} + self.available = True def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" - return [self.accessory.to_accessory_and_service_list()] + accessories = [ + a.to_accessory_and_service_list() for a in self.accessories + ] + # replicate what happens upstream right now + self.pairing_data['accessories'] = accessories + return accessories def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" + if not self.available: + raise AccessoryNotFoundError('Accessory not found') + results = {} for aid, cid in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - results[(aid, cid)] = { - 'value': char.get_value() - } + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + results[(aid, cid)] = { + 'value': char.get_value() + } return results def put_characteristics(self, characteristics): """Fake implementation of put_characteristics.""" - for _, cid, new_val in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - char.set_value(new_val) + for aid, cid, new_val in characteristics: + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + char.set_value(new_val) class FakeController: @@ -68,9 +81,9 @@ class FakeController: """Create a Fake controller with no pairings.""" self.pairings = {} - def add(self, accessory): + def add(self, accessories): """Create and register a fake pairing for a simulated accessory.""" - pairing = FakePairing(accessory) + pairing = FakePairing(accessories) self.pairings['00:00:00:00:00:00'] = pairing return pairing @@ -134,10 +147,26 @@ class FakeService(AbstractService): return char -async def setup_test_component(hass, services, capitalize=False): +async def setup_platform(hass): + """Load the platform but with a fake Controller API.""" + config = { + 'discovery': { + } + } + + with mock.patch('homekit.Controller') as controller: + fake_controller = controller.return_value = FakeController() + await async_setup_component(hass, DOMAIN, config) + + return fake_controller + + +async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. If capitalize is True, property names will be in upper case. + + If suffix is set, entityId will include the suffix """ domain = None for service in services: @@ -148,18 +177,11 @@ async def setup_test_component(hass, services, capitalize=False): assert domain, 'Cannot map test homekit services to homeassistant domain' - config = { - 'discovery': { - } - } - - with mock.patch('homekit.Controller') as controller: - fake_controller = controller.return_value = FakeController() - await async_setup_component(hass, DOMAIN, config) + fake_controller = await setup_platform(hass) accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = fake_controller.add(accessory) + pairing = fake_controller.add([accessory]) discovery_info = { 'host': '127.0.0.1', @@ -174,4 +196,5 @@ async def setup_test_component(hass, services, capitalize=False): fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) await hass.async_block_till_done() - return Helper(hass, '.'.join((domain, 'testdevice')), pairing, accessory) + entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix) + return Helper(hass, '.'.join((domain, entity)), pairing, accessory) diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py new file mode 100644 index 00000000000..62c741b4eaa --- /dev/null +++ b/tests/components/homekit_controller/test_config_flow.py @@ -0,0 +1,791 @@ +"""Tests for homekit_controller config flow.""" +import json +from unittest import mock + +import homekit + +from homeassistant.components.homekit_controller import config_flow +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from tests.common import MockConfigEntry +from tests.components.homekit_controller.common import ( + Accessory, FakeService, setup_platform +) + + +async def test_discovery_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_upper_case(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'MD': 'TestDevice', + 'ID': '00:00:00:00:00:00', + 'C#': 1, + 'SF': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_missing_csharp(hass): + """Test a device being discovered that has missing mdns attrs.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_pair_already_paired_1(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_discovery_ignored_model(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'BSB002', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'ignored_model' + + +async def test_discovery_invalid_config_entry(hass): + """There is already a config entry for the pairing id but its invalid.""" + MockConfigEntry(domain='homekit_controller', data={ + 'AccessoryPairingID': '00:00:00:00:00:00' + }).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + # Discovery of a HKID that is in a pairable state but for which there is + # already a config entry - in that case the stale config entry is + # automatically removed. + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 0 + + +async def test_discovery_already_configured(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_count == 0 + + +async def test_discovery_already_configured_config_change(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 2, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_args == mock.call(2) + + +async def test_pair_unable_to_pair(hass): + """Pairing completed without exception, but didn't create a pairing.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unable_to_pair' + + +async def test_pair_authentication_error(hass): + """Pairing code is incorrect.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.AuthenticationError('Invalid pairing code') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'authentication_error' + + +async def test_pair_unknown_error(hass): + """Pairing failed for an unknown rason.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnknownError('Unknown error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unknown_error' + + +async def test_pair_already_paired(hass): + """Device is already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnavailableError('Unavailable error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_import_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: + pairing_cls.return_value = pairing + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_import_already_configured(hass): + """Test importing a device from .homekit that is already a ConfigEntry.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + config_entry = MockConfigEntry( + domain='homekit_controller', + data=import_info + ) + config_entry.add_to_hass(hass) + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + +async def test_user_works(hass): + """Test user initiated disovers devices.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + controller.discover.return_value = [ + discovery_info, + ] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + result = await flow.async_step_user({ + 'device': '00:00:00:00:00:00', + }) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_user_no_devices(hass): + """Test user initiated pairing where no devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_user_no_unpaired_devices(hass): + """Test user initiated pairing where no unpaired devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [ + discovery_info, + ] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_parse_new_homekit_json(hass): + """Test migrating recent .homekit/pairings.json files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, False] + + read_data = { + '00:00:00:00:00:00': pairing.pairing_data, + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_old_homekit_json(hass): + """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [False, True] + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + read_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_overlapping_homekit_json(hass): + """Test migrating .homekit/pairings.json files when hk- exists too.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, True] + + # First file to get loaded is .homekit/pairing.json + read_data_1 = { + '00:00:00:00:00:00': { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + } + mock_open_1 = mock.mock_open(read_data=json.dumps(read_data_1)) + + # Second file to get loaded is .homekit/hk-00:00:00:00:00:00 + read_data_2 = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open_2 = mock.mock_open(read_data=json.dumps(read_data_2)) + + side_effects = [mock_open_1.return_value, mock_open_2.return_value] + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', side_effect=side_effects): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + await hass.async_block_till_done() + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 0509d70c0b9..59363f72146 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -126,3 +126,29 @@ async def test_switch_read_light_state_color_temp(hass, utcnow): assert state.state == 'on' assert state.attributes['brightness'] == 255 assert state.attributes['color_temp'] == 400 + + +async def test_light_becomes_unavailable_but_recovers(hass, utcnow): + """Test transition to and from unavailable state.""" + bulb = create_lightbulb_service_with_color_temp() + helper = await setup_test_component(hass, [bulb]) + + # Initial state is that the light is off + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Test device goes offline + helper.pairing.available = False + state = await helper.poll_and_get_state() + assert state.state == 'unavailable' + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[LIGHT_ON].set_value(True) + helper.characteristics[LIGHT_BRIGHTNESS].value = 100 + helper.characteristics[LIGHT_COLOR_TEMP].value = 400 + helper.pairing.available = True + + state = await helper.poll_and_get_state() + assert state.state == 'on' + assert state.attributes['brightness'] == 255 + assert state.attributes['color_temp'] == 400 diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py new file mode 100644 index 00000000000..c4311926636 --- /dev/null +++ b/tests/components/homekit_controller/test_sensor.py @@ -0,0 +1,79 @@ +"""Basic checks for HomeKit sensor.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +TEMPERATURE = ('temperature', 'temperature.current') +HUMIDITY = ('humidity', 'relative-humidity.current') +LIGHT_LEVEL = ('light', 'light-level.current') + + +def create_temperature_sensor_service(): + """Define temperature characteristics.""" + service = FakeService('public.hap.service.sensor.temperature') + + cur_state = service.add_characteristic('temperature.current') + cur_state.value = 0 + + return service + + +def create_humidity_sensor_service(): + """Define humidity characteristics.""" + service = FakeService('public.hap.service.sensor.humidity') + + cur_state = service.add_characteristic('relative-humidity.current') + cur_state.value = 0 + + return service + + +def create_light_level_sensor_service(): + """Define light level characteristics.""" + service = FakeService('public.hap.service.sensor.light') + + cur_state = service.add_characteristic('light-level.current') + cur_state.value = 0 + + return service + + +async def test_temperature_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit temperature sensor accessory.""" + sensor = create_temperature_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="temperature") + + helper.characteristics[TEMPERATURE].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[TEMPERATURE].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' + + +async def test_humidity_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit humidity sensor accessory.""" + sensor = create_humidity_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="humidity") + + helper.characteristics[HUMIDITY].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[HUMIDITY].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' + + +async def test_light_level_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit temperature sensor accessory.""" + sensor = create_light_level_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="light_level") + + helper.characteristics[LIGHT_LEVEL].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[LIGHT_LEVEL].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 61ca3300d60..fd20360b8da 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -68,7 +68,7 @@ async def test_hap_setup_works(aioclient_mock): assert await hap.async_setup() is True assert hap.home is home - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8 assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'alarm_control_panel') assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ @@ -111,10 +111,10 @@ async def test_hap_reset_unloads_entry_if_setup(): assert hap.home is home assert len(hass.services.async_register.mock_calls) == 0 - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8 hass.config_entries.async_forward_entry_unload.return_value = \ mock_coro(True) await hap.async_reset() - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 7 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 8 diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 304bb4de997..a16b40213b8 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,7 +7,7 @@ import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.auth.providers import legacy_api_password +from homeassistant.auth.providers import trusted_networks from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -16,7 +16,7 @@ from homeassistant.setup import async_setup_component from . import mock_real_ip -API_PASSWORD = 'test1234' +API_PASSWORD = 'test-password' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases TRUSTED_NETWORKS = [ @@ -35,17 +35,22 @@ async def mock_handler(request): if not request[KEY_AUTHENTICATED]: raise HTTPUnauthorized - token = request.get('hass_refresh_token') - token_id = token.id if token else None user = request.get('hass_user') user_id = user.id if user else None return web.json_response(status=200, data={ - 'refresh_token_id': token_id, 'user_id': user_id, }) +async def get_legacy_user(auth): + """Get the user in legacy_api_password auth provider.""" + provider = auth.get_auth_provider('legacy_api_password', None) + return await auth.async_get_or_create_user( + await provider.async_get_or_create_credentials({}) + ) + + @pytest.fixture def app(hass): """Fixture to set up a web.Application.""" @@ -65,6 +70,19 @@ def app2(hass): return app +@pytest.fixture +def trusted_networks_auth(hass): + """Load trusted networks auth provider.""" + prv = trusted_networks.TrustedNetworksAuthProvider( + hass, hass.auth._store, { + 'type': 'trusted_networks', + 'trusted_networks': TRUSTED_NETWORKS, + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + return prv + + async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_auth') as mock_setup: @@ -78,15 +96,14 @@ async def test_auth_middleware_loaded_by_default(hass): async def test_access_with_password_in_header(app, aiohttp_client, legacy_auth, hass): """Test access with password in header.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -98,16 +115,15 @@ async def test_access_with_password_in_header(app, aiohttp_client, async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, hass): """Test access with password in URL.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 assert await resp.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -122,16 +138,15 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -153,9 +168,11 @@ async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): assert req.status == 401 -async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): +async def test_access_with_trusted_ip(hass, app2, trusted_networks_auth, + aiohttp_client, + hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, api_password='some-pass') + setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -172,7 +189,6 @@ async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): assert resp.status == 200, \ "{} should be trusted".format(remote_addr) assert await resp.json() == { - 'refresh_token_id': None, 'user_id': hass_owner_user.id, } @@ -181,7 +197,7 @@ async def test_auth_active_access_with_access_token_in_header( hass, app, aiohttp_client, hass_access_token): """Test access with access token in header.""" token = hass_access_token - setup_auth(app, [], api_password=None) + setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) @@ -190,7 +206,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -198,7 +213,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -206,7 +220,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -226,10 +239,12 @@ async def test_auth_active_access_with_access_token_in_header( assert req.status == 401 -async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, +async def test_auth_active_access_with_trusted_ip(hass, app2, + trusted_networks_auth, + aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, None) + setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -246,7 +261,6 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, assert resp.status == 200, \ "{} should be trusted".format(remote_addr) assert await resp.json() == { - 'refresh_token_id': None, 'user_id': hass_owner_user.id, } @@ -254,15 +268,14 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, async def test_auth_legacy_support_api_password_access( app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" - setup_auth(app, [], API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -271,7 +284,6 @@ async def test_auth_legacy_support_api_password_access( }) assert resp.status == 200 assert await resp.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -280,7 +292,6 @@ async def test_auth_legacy_support_api_password_access( auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -290,7 +301,7 @@ async def test_auth_access_signed_path( """Test access with signed url.""" app.router.add_post('/', mock_handler) app.router.add_get('/another_path', mock_handler) - setup_auth(app, [], None) + setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( @@ -303,7 +314,6 @@ async def test_auth_access_signed_path( req = await client.get(signed_path) assert req.status == 200 data = await req.json() - assert data['refresh_token_id'] == refresh_token.id assert data['user_id'] == refresh_token.user.id # Use signature on other path diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c95146d5cca..e17fb105efe 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -7,11 +7,14 @@ from aiohttp.hdrs import ( ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, + AUTHORIZATION, ORIGIN ) import pytest -from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.const import ( + HTTP_HEADER_HA_AUTH +) from homeassistant.setup import async_setup_component from homeassistant.components.http.cors import setup_cors from homeassistant.components.http.view import HomeAssistantView @@ -84,6 +87,15 @@ async def test_cors_requests(client): assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ TRUSTED_ORIGIN + # With auth token in headers + req = await client.get('/', headers={ + AUTHORIZATION: 'Bearer some-token', + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + async def test_cors_preflight_allowed(client): """Test cross origin resource sharing preflight (OPTIONS) request.""" diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index fadb91a3e03..a753dd275fe 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -143,15 +143,13 @@ async def test_api_base_url_removes_trailing_slash(hass): async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: 'some-pass' - } + 'http': {} }) client = await aiohttp_client(hass.http.app) logging.getLogger('aiohttp.access').setLevel(logging.INFO) resp = await client.get('/api/', params={ - 'api_password': 'some-pass' + 'api_password': 'test-password' }) assert resp.status == 200 diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index c8ade907dd3..9d69affae4a 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -148,10 +148,11 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['old_state'] = None - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -172,10 +173,11 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['new_state'] = None - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -196,10 +198,11 @@ class TestComponentLogbook(unittest.TestCase): {ATTR_HIDDEN: 'true'}) eventB = self.create_state_changed_event(pointB, entity_id2, 20) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -223,9 +226,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -249,11 +254,13 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), - ha.Event(EVENT_ALEXA_SMART_HOME), - ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + ha.Event(EVENT_ALEXA_SMART_HOME), + ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -283,9 +290,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -316,9 +325,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -342,9 +353,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_ENTITIES: [entity_id2, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -378,10 +391,12 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), - event_alexa, event_homekit, eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + event_alexa, event_homekit, eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 4 == len(entries) @@ -415,10 +430,13 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', ], logbook.CONF_ENTITIES: ['sensor.bli', ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA1, eventA2, eventA3, - eventB1, eventB2), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + eventA1, eventA2, eventA3, + eventB1, eventB2) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 5 == len(entries) @@ -443,9 +461,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - events = logbook._exclude_events( - (eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) @@ -463,9 +482,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event( pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB) - events = logbook._exclude_events( - (eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index becdc2841f3..cf617ff0528 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -1 +1,72 @@ """Tests for mobile_app component.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from tests.common import mock_device_registry + +from homeassistant.setup import async_setup_component + +from homeassistant.components.mobile_app.const import (DATA_BINARY_SENSOR, + DATA_DELETED_IDS, + DATA_SENSOR, + DOMAIN, + STORAGE_KEY, + STORAGE_VERSION) + +from .const import REGISTER, REGISTER_CLEARTEXT + + +@pytest.fixture +def registry(hass): + """Return a configured device registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +async def create_registrations(authed_api_client): + """Return two new registrations.""" + enc_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert enc_reg.status == 201 + enc_reg_json = await enc_reg.json() + + clear_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT + ) + + assert clear_reg.status == 201 + clear_reg_json = await clear_reg.json() + + return (enc_reg_json, clear_reg_json) + + +@pytest.fixture +async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): + """mobile_app mock client.""" + hass_storage[STORAGE_KEY] = { + 'version': STORAGE_VERSION, + 'data': { + DATA_BINARY_SENSOR: {}, + DATA_DELETED_IDS: [], + DATA_SENSOR: {} + } + } + + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + return await aiohttp_client(hass.http.app) + + +@pytest.fixture +async def authed_api_client(hass, hass_client): + """Provide an authenticated client for mobile_app to use.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + return await hass_client() + + +@pytest.fixture(autouse=True) +async def setup_ws(hass): + """Configure the websocket_api component.""" + assert await async_setup_component(hass, 'websocket_api', {}) diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py new file mode 100644 index 00000000000..6dfe050191b --- /dev/null +++ b/tests/components/mobile_app/const.py @@ -0,0 +1,65 @@ +"""Constants for mobile_app tests.""" +CALL_SERVICE = { + 'type': 'call_service', + 'data': { + 'domain': 'test', + 'service': 'mobile_app', + 'service_data': { + 'foo': 'bar' + } + } +} + +FIRE_EVENT = { + 'type': 'fire_event', + 'data': { + 'event_type': 'test_event', + 'event_data': { + 'hello': 'yo world' + } + } +} + +REGISTER = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_name': 'Linux', + 'os_version': '1.0', + 'supports_encryption': True +} + +REGISTER_CLEARTEXT = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_name': 'Linux', + 'os_version': '1.0', + 'supports_encryption': False +} + +RENDER_TEMPLATE = { + 'type': 'render_template', + 'data': { + 'one': { + 'template': 'Hello world' + } + } +} + +UPDATE = { + 'app_data': {'foo': 'bar'}, + 'app_version': '2.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0' +} diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py new file mode 100644 index 00000000000..5dc285cfe9e --- /dev/null +++ b/tests/components/mobile_app/test_entity.py @@ -0,0 +1,133 @@ +"""Entity tests for mobile_app.""" +# pylint: disable=redefined-outer-name,unused-import +import logging + +from . import (authed_api_client, create_registrations, # noqa: F401 + webhook_client) # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + + +async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401, F811, E501 + """Test that sensors can be registered and updated.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + reg_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {'status': 'registered'} + + entity = hass.states.get('sensor.battery_state') + assert entity is not None + + assert entity.attributes['device_class'] == 'battery' + assert entity.attributes['icon'] == 'mdi:battery' + assert entity.attributes['unit_of_measurement'] == '%' + assert entity.attributes['foo'] == 'bar' + assert entity.domain == 'sensor' + assert entity.name == 'Battery State' + assert entity.state == '100' + + update_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'icon': 'mdi:battery-unknown', + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert update_resp.status == 200 + + updated_entity = hass.states.get('sensor.battery_state') + assert updated_entity.state == '123' + + +async def test_sensor_must_register(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must be registered before updating.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert resp.status == 200 + + json = await resp.json() + assert json['battery_state']['success'] is False + assert json['battery_state']['error']['code'] == 'not_registered' + + +async def test_sensor_id_no_dupes(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must have a unique ID.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + payload = { + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + + reg_resp = await webhook_client.post(webhook_url, json=payload) + + assert reg_resp.status == 201 + + reg_json = await reg_resp.json() + assert reg_json == {'status': 'registered'} + + dupe_resp = await webhook_client.post(webhook_url, json=payload) + + assert dupe_resp.status == 409 + + dupe_json = await dupe_resp.json() + assert dupe_json['success'] is False + assert dupe_json['error']['code'] == 'duplicate_unique_id' diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py new file mode 100644 index 00000000000..eb9d1f54d93 --- /dev/null +++ b/tests/components/mobile_app/test_http_api.py @@ -0,0 +1,107 @@ +"""Tests for the mobile_app HTTP API.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component + +from .const import REGISTER, RENDER_TEMPLATE +from . import authed_api_client # noqa: F401 + + +async def test_registration(hass, hass_client): # noqa: F811 + """Test that registrations happen.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + api_client = await hass_client() + + resp = await api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert resp.status == 201 + register_json = await resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + entries = hass.config_entries.async_entries(DOMAIN) + + assert entries[0].data['app_data'] == REGISTER['app_data'] + assert entries[0].data['app_id'] == REGISTER['app_id'] + assert entries[0].data['app_name'] == REGISTER['app_name'] + assert entries[0].data['app_version'] == REGISTER['app_version'] + assert entries[0].data['device_name'] == REGISTER['device_name'] + assert entries[0].data['manufacturer'] == REGISTER['manufacturer'] + assert entries[0].data['model'] == REGISTER['model'] + assert entries[0].data['os_name'] == REGISTER['os_name'] + assert entries[0].data['os_version'] == REGISTER['os_version'] + assert entries[0].data['supports_encryption'] == \ + REGISTER['supports_encryption'] + + keylen = SecretBox.KEY_SIZE + key = register_json[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + resp = await api_client.post( + '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), + json=container + ) + + assert resp.status == 200 + + webhook_json = await resp.json() + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'one': 'Hello world'} + + +async def test_register_invalid_component(authed_api_client): # noqa: F811 + """Test that registration with invalid component fails.""" + resp = await authed_api_client.post( + '/api/mobile_app/registrations', json={ + 'app_component': 'will_never_be_valid', + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_name': 'Linux', + 'os_version': '1.0', + 'supports_encryption': True + } + ) + + assert resp.status == 400 + register_json = await resp.json() + assert 'error' in register_json + assert register_json['success'] is False + assert register_json['error']['code'] == 'invalid_component' diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py deleted file mode 100644 index d0c1ae02c6c..00000000000 --- a/tests/components/mobile_app/test_init.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Test the mobile_app_http platform.""" -import pytest - -from homeassistant.setup import async_setup_component - -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.components.mobile_app import (DOMAIN, STORAGE_KEY, - STORAGE_VERSION, - CONF_SECRET, CONF_USER_ID) -from homeassistant.core import callback - -from tests.common import async_mock_service - -FIRE_EVENT = { - 'type': 'fire_event', - 'data': { - 'event_type': 'test_event', - 'event_data': { - 'hello': 'yo world' - } - } -} - -RENDER_TEMPLATE = { - 'type': 'render_template', - 'data': { - 'template': 'Hello world' - } -} - -CALL_SERVICE = { - 'type': 'call_service', - 'data': { - 'domain': 'test', - 'service': 'mobile_app', - 'service_data': { - 'foo': 'bar' - } - } -} - -REGISTER = { - 'app_data': {'foo': 'bar'}, - 'app_id': 'io.homeassistant.mobile_app_test', - 'app_name': 'Mobile App Tests', - 'app_version': '1.0.0', - 'device_name': 'Test 1', - 'manufacturer': 'mobile_app', - 'model': 'Test', - 'os_version': '1.0', - 'supports_encryption': True -} - -UPDATE = { - 'app_data': {'foo': 'bar'}, - 'app_version': '2.0.0', - 'device_name': 'Test 1', - 'manufacturer': 'mobile_app', - 'model': 'Test', - 'os_version': '1.0' -} - -# pylint: disable=redefined-outer-name - - -@pytest.fixture -def mobile_app_client(hass, aiohttp_client, hass_storage, hass_admin_user): - """mobile_app mock client.""" - hass_storage[STORAGE_KEY] = { - 'version': STORAGE_VERSION, - 'data': { - 'mobile_app_test': { - CONF_SECRET: '58eb127991594dad934d1584bdee5f27', - 'supports_encryption': True, - CONF_WEBHOOK_ID: 'mobile_app_test', - 'device_name': 'Test Device', - CONF_USER_ID: hass_admin_user.id, - } - } - } - - assert hass.loop.run_until_complete(async_setup_component( - hass, DOMAIN, { - DOMAIN: {} - })) - - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@pytest.fixture -async def mock_api_client(hass, hass_client): - """Provide an authenticated client for mobile_app to use.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - return await hass_client() - - -async def test_handle_render_template(mobile_app_client): - """Test that we render templates properly.""" - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=RENDER_TEMPLATE - ) - - assert resp.status == 200 - - json = await resp.json() - assert json == {'rendered': 'Hello world'} - - -async def test_handle_call_services(hass, mobile_app_client): - """Test that we call services properly.""" - calls = async_mock_service(hass, 'test', 'mobile_app') - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=CALL_SERVICE - ) - - assert resp.status == 200 - - assert len(calls) == 1 - - -async def test_handle_fire_event(hass, mobile_app_client): - """Test that we can fire events.""" - events = [] - - @callback - def store_event(event): - """Helepr to store events.""" - events.append(event) - - hass.bus.async_listen('test_event', store_event) - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=FIRE_EVENT - ) - - assert resp.status == 200 - text = await resp.text() - assert text == "" - - assert len(events) == 1 - assert events[0].data['hello'] == 'yo world' - - -async def test_update_registration(mobile_app_client, hass_client): - """Test that a we can update an existing registration via webhook.""" - mock_api_client = await hass_client() - register_resp = await mock_api_client.post( - '/api/mobile_app/devices', json=REGISTER - ) - - assert register_resp.status == 201 - register_json = await register_resp.json() - - webhook_id = register_json[CONF_WEBHOOK_ID] - - update_container = { - 'type': 'update_registration', - 'data': UPDATE - } - - update_resp = await mobile_app_client.post( - '/api/webhook/{}'.format(webhook_id), json=update_container - ) - - assert update_resp.status == 200 - update_json = await update_resp.json() - assert update_json['app_version'] == '2.0.0' - assert CONF_WEBHOOK_ID not in update_json - assert CONF_SECRET not in update_json - - -async def test_returns_error_incorrect_json(mobile_app_client, caplog): - """Test that an error is returned when JSON is invalid.""" - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - data='not json' - ) - - assert resp.status == 400 - json = await resp.json() - assert json == [] - assert 'invalid JSON' in caplog.text - - -async def test_handle_decryption(mobile_app_client): - """Test that we can encrypt/decrypt properly.""" - try: - # pylint: disable=unused-import - from nacl.secret import SecretBox # noqa: F401 - from nacl.encoding import Base64Encoder # noqa: F401 - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - - keylen = SecretBox.KEY_SIZE - key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") - - data = SecretBox(key).encrypt(payload, - encoder=Base64Encoder).decode("utf-8") - - container = { - 'type': 'render_template', - 'encrypted': True, - 'encrypted_data': data, - } - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=container - ) - - assert resp.status == 200 - - json = await resp.json() - assert json == {'rendered': 'Hello world'} - - -async def test_register_device(hass_client, mock_api_client): - """Test that a device can be registered.""" - try: - # pylint: disable=unused-import - from nacl.secret import SecretBox # noqa: F401 - from nacl.encoding import Base64Encoder # noqa: F401 - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - - resp = await mock_api_client.post( - '/api/mobile_app/devices', json=REGISTER - ) - - assert resp.status == 201 - register_json = await resp.json() - assert CONF_WEBHOOK_ID in register_json - assert CONF_SECRET in register_json - - keylen = SecretBox.KEY_SIZE - key = register_json[CONF_SECRET].encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") - - data = SecretBox(key).encrypt(payload, - encoder=Base64Encoder).decode("utf-8") - - container = { - 'type': 'render_template', - 'encrypted': True, - 'encrypted_data': data, - } - - mobile_app_client = await hass_client() - - resp = await mobile_app_client.post( - '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), - json=container - ) - - assert resp.status == 200 - - webhook_json = await resp.json() - assert webhook_json == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py new file mode 100644 index 00000000000..a70e8ba1275 --- /dev/null +++ b/tests/components/mobile_app/test_webhook.py @@ -0,0 +1,177 @@ +"""Webhook tests for mobile_app.""" +# pylint: disable=redefined-outer-name,unused-import +import logging +import pytest + +from homeassistant.components.mobile_app.const import CONF_SECRET +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback + +from tests.common import async_mock_service + +from . import (authed_api_client, create_registrations, # noqa: F401 + webhook_client) # noqa: F401 + +from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, + RENDER_TEMPLATE, UPDATE) + +_LOGGER = logging.getLogger(__name__) + + +async def test_webhook_handle_render_template(create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F811 + """Test that we render templates properly.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json=RENDER_TEMPLATE + ) + + assert resp.status == 200 + + json = await resp.json() + assert json == {'one': 'Hello world'} + + +async def test_webhook_handle_call_services(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: E501 F811 + """Test that we call services properly.""" + calls = async_mock_service(hass, 'test', 'mobile_app') + + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json=CALL_SERVICE + ) + + assert resp.status == 200 + + assert len(calls) == 1 + + +async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F811 + """Test that we can fire events.""" + events = [] + + @callback + def store_event(event): + """Helepr to store events.""" + events.append(event) + + hass.bus.async_listen('test_event', store_event) + + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + json=FIRE_EVENT + ) + + assert resp.status == 200 + json = await resp.json() + assert json == {} + + assert len(events) == 1 + assert events[0].data['hello'] == 'yo world' + + +async def test_webhook_update_registration(webhook_client, hass_client): # noqa: E501 F811 + """Test that a we can update an existing registration via webhook.""" + authed_api_client = await hass_client() # noqa: F811 + register_resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + + webhook_id = register_json[CONF_WEBHOOK_ID] + + update_container = { + 'type': 'update_registration', + 'data': UPDATE + } + + update_resp = await webhook_client.post( + '/api/webhook/{}'.format(webhook_id), json=update_container + ) + + assert update_resp.status == 200 + update_json = await update_resp.json() + assert update_json['app_version'] == '2.0.0' + assert CONF_WEBHOOK_ID not in update_json + assert CONF_SECRET not in update_json + + +async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F401, F811, E501 + create_registrations, # noqa: F401, F811, E501 + caplog): # noqa: E501 F811 + """Test that an error is returned when JSON is invalid.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), + data='not json' + ) + + assert resp.status == 400 + json = await resp.json() + assert json == {} + assert 'invalid JSON' in caplog.text + + +async def test_webhook_handle_decryption(webhook_client, # noqa: F811 + create_registrations): # noqa: F401, F811, E501 + """Test that we can encrypt/decrypt properly.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + keylen = SecretBox.KEY_SIZE + key = create_registrations[0]['secret'].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), + json=container + ) + + assert resp.status == 200 + + webhook_json = await resp.json() + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'one': 'Hello world'} + + +async def test_webhook_requires_encryption(webhook_client, # noqa: F811 + create_registrations): # noqa: F401, F811, E501 + """Test that encrypted registrations only accept encrypted data.""" + resp = await webhook_client.post( + '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), + json=RENDER_TEMPLATE + ) + + assert resp.status == 400 + + webhook_json = await resp.json() + assert 'error' in webhook_json + assert webhook_json['success'] is False + assert webhook_json['error']['code'] == 'encryption_required' diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py new file mode 100644 index 00000000000..ee656159d2e --- /dev/null +++ b/tests/components/mobile_app/test_websocket_api.py @@ -0,0 +1,80 @@ +"""Test the mobile_app websocket API.""" +# pylint: disable=redefined-outer-name,unused-import +from homeassistant.components.mobile_app.const import (CONF_SECRET, DOMAIN) +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component + +from . import authed_api_client, setup_ws, webhook_client # noqa: F401 +from .const import (CALL_SERVICE, REGISTER) + + +async def test_webocket_get_user_registrations(hass, aiohttp_client, + hass_ws_client, + hass_read_only_access_token): + """Test get_user_registrations websocket command from admin perspective.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + user_api_client = await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_read_only_access_token) + }) + + # First a read only user registers. + register_resp = await user_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + # Then the admin user attempts to access it. + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mobile_app/get_user_registrations', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert len(msg['result']) == 1 + + +async def test_webocket_delete_registration(hass, hass_client, + hass_ws_client, webhook_client): # noqa: E501 F811 + """Test delete_registration websocket command.""" + authed_api_client = await hass_client() # noqa: F811 + register_resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + webhook_id = register_json[CONF_WEBHOOK_ID] + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mobile_app/delete_registration', + CONF_WEBHOOK_ID: webhook_id, + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result'] == 'ok' + + ensure_four_ten_gone = await webhook_client.post( + '/api/webhook/{}'.format(webhook_id), json=CALL_SERVICE + ) + + assert ensure_four_ten_gone.status == 410 diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 81c993ed311..742aafba8dc 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -114,15 +114,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_HOME', 0, False) - def test_arm_home_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) @@ -131,6 +135,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_home_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_home(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_HOME', 0, False) + def test_arm_away_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -147,15 +172,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_AWAY', 0, False) - def test_arm_away_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid code. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) @@ -164,6 +193,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_away_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_away(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_AWAY', 0, False) + def test_arm_night_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -180,15 +230,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_NIGHT', 0, False) - def test_arm_night_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid code. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) @@ -197,6 +251,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_night_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_night(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_NIGHT', 0, False) + def test_disarm_publishes_mqtt(self): """Test publishing of MQTT messages while disarmed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 38b38ff7648..b7f8b8338a0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -105,6 +105,7 @@ async def test_custom_availability_payload(hass, mqtt_mock): async_fire_mqtt_message(hass, 'availability_topic', 'good') await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('fan.test') assert state.state is not STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 94506efa909..5c441a68bea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -316,8 +316,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload unsub() @@ -343,8 +343,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic/bier/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic/bier/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_no_subtree_match(self): """Test the subscription of wildcard topics.""" @@ -372,8 +372,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic/bier/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic/bier/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_subtree_wildcard_root_topic(self): """Test the subscription of wildcard topics.""" @@ -383,8 +383,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_subtree_wildcard_no_match(self): """Test the subscription of wildcard topics.""" @@ -403,8 +403,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'hi/test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'hi/test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic(self): """Test the subscription of wildcard topics.""" @@ -414,8 +414,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'hi/test-topic/here-iam' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'hi/test-topic/here-iam' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match(self): """Test the subscription of wildcard topics.""" @@ -443,8 +443,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/subtree/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/subtree/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_sys_root_and_wildcard_topic(self): """Test the subscription of $ root and wildcard topics.""" @@ -454,8 +454,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/some-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/some-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_sys_root_and_wildcard_subtree_topic(self): """Test the subscription of $ root and wildcard subtree topics.""" @@ -466,8 +466,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/subtree/some-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/subtree/some-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_special_characters(self): """Test the subscription to topics with special characters.""" @@ -479,8 +479,8 @@ class TestMQTTCallbacks(unittest.TestCase): fire_mqtt_message(self.hass, topic, payload) self.hass.block_till_done() assert 1 == len(self.calls) - assert topic == self.calls[0][0] - assert payload == self.calls[0][1] + assert topic == self.calls[0][0].topic + assert payload == self.calls[0][0].payload def test_mqtt_failed_connection_results_in_disconnect(self): """Test if connection failure leads to disconnect.""" @@ -767,3 +767,37 @@ async def test_message_callback_exception_gets_logged(hass, caplog): assert \ "Exception in bad_handler when handling msg on 'test-topic':" \ " 'test'" in caplog.text + + +async def test_mqtt_ws_subscription(hass, hass_ws_client): + """Test MQTT websocket subscription.""" + await async_mock_mqtt_component(hass) + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mqtt/subscribe', + 'topic': 'test-topic', + }) + response = await client.receive_json() + assert response['success'] + + async_fire_mqtt_message(hass, 'test-topic', 'test1') + async_fire_mqtt_message(hass, 'test-topic', 'test2') + + response = await client.receive_json() + assert response['event']['topic'] == 'test-topic' + assert response['event']['payload'] == 'test1' + + response = await client.receive_json() + assert response['event']['topic'] == 'test-topic' + assert response['event']['payload'] == 'test2' + + # Unsubscribe + await client.send_json({ + 'id': 8, + 'type': 'unsubscribe_events', + 'subscription': 5, + }) + response = await client.receive_json() + assert response['success'] diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index a0ae0ddb2fb..172e6dbd8cf 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -235,6 +235,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, 'test_light_rgb', '{"state":"ON", "color":{"x":0.135,"y":0.135}}') await hass.async_block_till_done() + await hass.async_block_till_done() light_state = hass.states.get('light.test') assert (0.141, 0.14) == \ diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 2589adf2f9c..71ef1dc1e43 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -19,24 +19,6 @@ class TestMQTT: """Stop everything that was started.""" self.hass.stop() - @patch('passlib.apps.custom_app_context', Mock(return_value='')) - @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) - @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) - @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) - @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_with_http_pass_only(self, mock_mqtt): - """Test if the MQTT server failed starts. - - Since 0.77, MQTT server has to set up its own password. - If user has api_password but don't have mqtt.password, MQTT component - will fail to start - """ - mock_mqtt().async_connect.return_value = mock_coro(True) - self.hass.bus.listen_once = MagicMock() - assert not setup_component(self.hass, mqtt.DOMAIN, { - 'http': {'api_password': 'http_secret'} - }) - @patch('passlib.apps.custom_app_context', Mock(return_value='')) @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index b4b005d0d1e..cd274079e01 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -35,8 +35,8 @@ async def test_subscribe_topics(hass, mqtt_mock, caplog): async_fire_mqtt_message(hass, 'test-topic1', 'test-payload1') await hass.async_block_till_done() assert 1 == len(calls1) - assert 'test-topic1' == calls1[0][0] - assert 'test-payload1' == calls1[0][1] + assert 'test-topic1' == calls1[0][0].topic + assert 'test-payload1' == calls1[0][0].payload assert 0 == len(calls2) async_fire_mqtt_message(hass, 'test-topic2', 'test-payload2') @@ -44,8 +44,8 @@ async def test_subscribe_topics(hass, mqtt_mock, caplog): await hass.async_block_till_done() assert 1 == len(calls1) assert 1 == len(calls2) - assert 'test-topic2' == calls2[0][0] - assert 'test-payload2' == calls2[0][1] + assert 'test-topic2' == calls2[0][0].topic + assert 'test-payload2' == calls2[0][0].payload await async_unsubscribe_topics(hass, sub_state) @@ -108,8 +108,8 @@ async def test_modify_topics(hass, mqtt_mock, caplog): await hass.async_block_till_done() await hass.async_block_till_done() assert 2 == len(calls1) - assert 'test-topic1_1' == calls1[1][0] - assert 'test-payload' == calls1[1][1] + assert 'test-topic1_1' == calls1[1][0].topic + assert 'test-payload' == calls1[1][0].payload assert 1 == len(calls2) await async_unsubscribe_topics(hass, sub_state) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 5b303943747..fdf472f3b13 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component from homeassistant.components import onboarding from homeassistant.components.onboarding import views -from tests.common import register_auth_provider +from tests.common import CLIENT_ID, register_auth_provider from . import mock_storage @@ -59,6 +59,7 @@ async def test_onboarding_user_already_done(hass, hass_storage, client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', @@ -79,12 +80,16 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', }) assert resp.status == 200 + data = await resp.json() + assert 'auth_code' in data + users = await hass.auth.async_get_users() assert len(users) == 1 user = users[0] @@ -93,6 +98,21 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): assert user.credentials[0].data['username'] == 'test-user' assert len(hass.data['person'].storage_data) == 1 + # Request refresh tokens + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': data['auth_code'] + }) + + assert resp.status == 200 + tokens = await resp.json() + + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) + async def test_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): @@ -106,6 +126,7 @@ async def test_onboarding_user_invalid_name(hass, hass_storage, client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', }) @@ -124,11 +145,13 @@ async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): client = await aiohttp_client(hass.http.app) resp1 = client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test 1', 'username': '1-user', 'password': '1-pass', }) resp2 = client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test 2', 'username': '2-user', 'password': '2-pass', diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index b0170beeb48..271db46d856 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -14,20 +14,32 @@ MOCK_TITLE = 'PlayStation 4' MOCK_CODE = '12345678' MOCK_CREDS = '000aa000' MOCK_HOST = '192.0.0.0' +MOCK_HOST_ADDITIONAL = '192.0.0.1' MOCK_DEVICE = { CONF_HOST: MOCK_HOST, CONF_NAME: DEFAULT_NAME, CONF_REGION: DEFAULT_REGION } +MOCK_DEVICE_ADDITIONAL = { + CONF_HOST: MOCK_HOST_ADDITIONAL, + CONF_NAME: DEFAULT_NAME, + CONF_REGION: DEFAULT_REGION +} MOCK_CONFIG = { CONF_IP_ADDRESS: MOCK_HOST, CONF_NAME: DEFAULT_NAME, CONF_REGION: DEFAULT_REGION, CONF_CODE: MOCK_CODE } +MOCK_CONFIG_ADDITIONAL = { + CONF_IP_ADDRESS: MOCK_HOST_ADDITIONAL, + CONF_NAME: DEFAULT_NAME, + CONF_REGION: DEFAULT_REGION, + CONF_CODE: MOCK_CODE +} MOCK_DATA = { CONF_TOKEN: MOCK_CREDS, - 'devices': MOCK_DEVICE + 'devices': [MOCK_DEVICE] } MOCK_UDP_PORT = int(987) MOCK_TCP_PORT = int(997) @@ -37,13 +49,14 @@ async def test_full_flow_implementation(hass): """Test registering an implementation and flow works.""" flow = ps4.PlayStation4FlowHandler() flow.hass = hass + manager = hass.config_entries # User Step Started, results in Step Creds with patch('pyps4_homeassistant.Helper.port_bind', return_value=None): result = await flow.async_step_user() - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'creds' + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'creds' # Step Creds results with form in Step Link. with patch('pyps4_homeassistant.Helper.get_creds', @@ -51,8 +64,8 @@ async def test_full_flow_implementation(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_creds({}) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' # User Input results in created entry. with patch('pyps4_homeassistant.Helper.link', @@ -60,10 +73,110 @@ async def test_full_flow_implementation(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['data'][CONF_TOKEN] == MOCK_CREDS - assert result['data']['devices'] == [MOCK_DEVICE] - assert result['title'] == MOCK_TITLE + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert result['data']['devices'] == [MOCK_DEVICE] + assert result['title'] == MOCK_TITLE + + # Add entry using result data. + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + + # Check if entry exists. + assert len(manager.async_entries()) == 1 + # Check if there is a device config in entry. + assert len(entry.data['devices']) == 1 + + +async def test_multiple_flow_implementation(hass): + """Test multiple device flows.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + manager = hass.config_entries + + # User Step Started, results in Step Creds + with patch('pyps4_homeassistant.Helper.port_bind', + return_value=None): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'creds' + + # Step Creds results with form in Step Link. + with patch('pyps4_homeassistant.Helper.get_creds', + return_value=MOCK_CREDS), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_creds({}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + # User Input results in created entry. + with patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_link(MOCK_CONFIG) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert result['data']['devices'] == [MOCK_DEVICE] + assert result['title'] == MOCK_TITLE + + await hass.async_block_till_done() + + # Add entry using result data. + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + + # Check if entry exists. + assert len(manager.async_entries()) == 1 + # Check if there is a device config in entry. + assert len(entry.data['devices']) == 1 + + # Test additional flow. + + # User Step Started, results in Step Link: + with patch('pyps4_homeassistant.Helper.port_bind', + return_value=None), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + # Step Link + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]), \ + patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)): + result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert len(result['data']['devices']) == 1 + assert result['title'] == MOCK_TITLE + + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + + # Update config entries with result data + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + manager.async_update_entry(entry) + + # Check if there are 2 entries. + assert len(manager.async_entries()) == 2 + # Check if there is device config in entry. + assert len(entry.data['devices']) == 1 async def test_port_bind_abort(hass): @@ -75,28 +188,62 @@ async def test_port_bind_abort(hass): return_value=MOCK_UDP_PORT): reason = 'port_987_bind_error' result = await flow.async_step_user(user_input=None) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == reason + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == reason with patch('pyps4_homeassistant.Helper.port_bind', return_value=MOCK_TCP_PORT): reason = 'port_997_bind_error' result = await flow.async_step_user(user_input=None) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == reason + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == reason async def test_duplicate_abort(hass): - """Test that Flow aborts when already configured.""" + """Test that Flow aborts when found devices already configured.""" MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA).add_to_hass(hass) flow = ps4.PlayStation4FlowHandler() flow.hass = hass - result = await flow.async_step_user(user_input=None) + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}]): + result = await flow.async_step_link(user_input=None) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'devices_configured' +async def test_additional_device(hass): + """Test that Flow can configure another device.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + flow.creds = MOCK_CREDS + manager = hass.config_entries + + # Mock existing entry. + entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA) + entry.add_to_manager(manager) + # Check that only 1 entry exists + assert len(manager.async_entries()) == 1 + + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]), \ + patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)): + result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert len(result['data']['devices']) == 1 + assert result['title'] == MOCK_TITLE + + # Add New Entry + entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA) + entry.add_to_manager(manager) + + # Check that there are 2 entries + assert len(manager.async_entries()) == 2 + + async def test_no_devices_found_abort(hass): """Test that failure to find devices aborts flow.""" flow = ps4.PlayStation4FlowHandler() @@ -104,8 +251,8 @@ async def test_no_devices_found_abort(hass): with patch('pyps4_homeassistant.Helper.has_devices', return_value=None): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'no_devices_found' + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_devices_found' async def test_credential_abort(hass): @@ -115,8 +262,8 @@ async def test_credential_abort(hass): with patch('pyps4_homeassistant.Helper.get_creds', return_value=None): result = await flow.async_step_creds({}) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'credential_error' + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'credential_error' async def test_invalid_pin_error(hass): @@ -129,9 +276,9 @@ async def test_invalid_pin_error(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'login_failed'} + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'login_failed'} async def test_device_connection_error(hass): @@ -144,6 +291,6 @@ async def test_device_connection_error(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'not_ready'} + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'not_ready'} diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index dbf1e1fe7dd..c2ea61e5bb4 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -6,6 +6,7 @@ Entity to be updated with new values. """ import asyncio +import datetime from decimal import Decimal from unittest.mock import Mock @@ -104,8 +105,8 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 1}, - {'value': 1, 'unit': 'm3'}, + {'value': datetime.datetime.fromtimestamp(1551642213)}, + {'value': Decimal(745.695), 'unit': 'm3'}, ]) } yield from entity.async_update() @@ -115,14 +116,14 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 2}, - {'value': 2, 'unit': 'm3'}, + {'value': datetime.datetime.fromtimestamp(1551642543)}, + {'value': Decimal(745.698), 'unit': 'm3'}, ]) } yield from entity.async_update() - assert entity.state == 1, \ - 'state should be difference between first and second update' + assert abs(entity.state - 0.033) < 0.00001, \ + 'state should be hourly usage calculated from first and second update' assert entity.unit_of_measurement == 'm3/h' diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 9b4e53dbab9..1bd3ee73616 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -2,6 +2,8 @@ import unittest import statistics +import pytest + from homeassistant.setup import setup_component from homeassistant.components.sensor.statistics import StatisticsSensor from homeassistant.const import ( @@ -228,6 +230,7 @@ class TestStatisticsSensor(unittest.TestCase): state.attributes.get('max_age') assert self.change_rate == state.attributes.get('change_rate') + @pytest.mark.skip def test_initialize_from_database(self): """Test initializing the statistics from the database.""" # enable the recorder diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 67c35ba8232..3f346c9df0d 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -85,7 +85,8 @@ def app_fixture(hass, config_file): 'appType': 'WEBHOOK_SMART_APP', 'classifications': [CLASSIFICATION_AUTOMATION], 'displayName': 'Home Assistant', - 'description': "Home Assistant at " + hass.config.api.base_url, + 'description': + hass.config.location_name + " at " + hass.config.api.base_url, 'singleInstance': True, 'webhookSmartApp': { 'targetUrl': webhook.async_generate_url( diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 29134d6ba6a..8789b3e7730 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -13,14 +13,15 @@ from homeassistant.components.climate.const import ( ATTR_FAN_MODE, ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE, - STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.smartthings import climate from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, STATE_OFF, - STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_UNKNOWN) from .conftest import setup_platform @@ -115,6 +116,41 @@ def buggy_thermostat_fixture(device_factory): return device +@pytest.fixture(name="air_conditioner") +def air_conditioner_fixture(device_factory): + """Fixture returns a air conditioner.""" + device = device_factory( + "Air Conditioner", + capabilities=[ + Capability.air_conditioner_mode, + Capability.demand_response_load_control, + Capability.fan_speed, + Capability.power_consumption_report, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint], + status={ + Attribute.air_conditioner_mode: 'auto', + Attribute.drlc_status: { + "duration": 0, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "override": False + }, + Attribute.fan_speed: 2, + Attribute.power_consumption: { + "start": "2019-02-24T21:03:04Z", + "power": 0, + "energy": 500, + "end": "2019-02-26T02:05:55Z" + }, + Attribute.switch: 'on', + Attribute.cooling_setpoint: 23} + ) + device.status.attributes[Attribute.temperature] = Status(24, 'C', None) + return device + + async def test_async_setup_platform(): """Test setup platform does nothing (it uses config entries).""" await climate.async_setup_platform(None, None, None) @@ -195,28 +231,61 @@ async def test_buggy_thermostat_invalid_mode(hass, buggy_thermostat): assert state.attributes[ATTR_OPERATION_LIST] == {'heat'} -async def test_set_fan_mode(hass, thermostat): +async def test_air_conditioner_entity_state(hass, air_conditioner): + """Tests when an invalid operation mode is included.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + assert state.attributes[ATTR_SUPPORTED_FEATURES] == \ + SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \ + SUPPORT_TARGET_TEMPERATURE | SUPPORT_ON_OFF + assert sorted(state.attributes[ATTR_OPERATION_LIST]) == [ + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT] + assert state.attributes[ATTR_FAN_MODE] == 'medium' + assert sorted(state.attributes[ATTR_FAN_LIST]) == \ + ['auto', 'high', 'low', 'medium', 'turbo'] + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 + assert state.attributes['drlc_status_duration'] == 0 + assert state.attributes['drlc_status_level'] == -1 + assert state.attributes['drlc_status_start'] == '1970-01-01T00:00:00Z' + assert state.attributes['drlc_status_override'] is False + assert state.attributes['power_consumption_start'] == \ + '2019-02-24T21:03:04Z' + assert state.attributes['power_consumption_power'] == 0 + assert state.attributes['power_consumption_energy'] == 500 + assert state.attributes['power_consumption_end'] == '2019-02-26T02:05:55Z' + + +async def test_set_fan_mode(hass, thermostat, air_conditioner): """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + await setup_platform(hass, CLIMATE_DOMAIN, + devices=[thermostat, air_conditioner]) + entity_ids = ['climate.thermostat', 'climate.air_conditioner'] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, { - ATTR_ENTITY_ID: 'climate.thermostat', + ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: 'auto'}, blocking=True) - state = hass.states.get('climate.thermostat') - assert state.attributes[ATTR_FAN_MODE] == 'auto' + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == 'auto', entity_id -async def test_set_operation_mode(hass, thermostat): +async def test_set_operation_mode(hass, thermostat, air_conditioner): """Test the operation mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + await setup_platform(hass, CLIMATE_DOMAIN, + devices=[thermostat, air_conditioner]) + entity_ids = ['climate.thermostat', 'climate.air_conditioner'] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_OPERATION_MODE, { - ATTR_ENTITY_ID: 'climate.thermostat', - ATTR_OPERATION_MODE: STATE_ECO}, + ATTR_ENTITY_ID: entity_ids, + ATTR_OPERATION_MODE: STATE_COOL}, blocking=True) - state = hass.states.get('climate.thermostat') - assert state.state == STATE_ECO + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == STATE_COOL, entity_id async def test_set_temperature_heat_mode(hass, thermostat): @@ -262,6 +331,32 @@ async def test_set_temperature(hass, thermostat): assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 +async def test_set_temperature_ac(hass, air_conditioner): + """Test the temperature is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: 'climate.air_conditioner', + ATTR_TEMPERATURE: 27}, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.attributes[ATTR_TEMPERATURE] == 27 + + +async def test_set_temperature_ac_with_mode(hass, air_conditioner): + """Test the temperature is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: 'climate.air_conditioner', + ATTR_TEMPERATURE: 27, + ATTR_OPERATION_MODE: STATE_COOL}, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.attributes[ATTR_TEMPERATURE] == 27 + assert state.state == STATE_COOL + + async def test_set_temperature_with_mode(hass, thermostat): """Test the temperature and mode is set successfully.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) @@ -278,6 +373,31 @@ async def test_set_temperature_with_mode(hass, thermostat): assert state.state == STATE_AUTO +async def test_set_turn_off(hass, air_conditioner): + """Test the a/c is turned off successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_OFF, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_OFF + + +async def test_set_turn_on(hass, air_conditioner): + """Test the a/c is turned on successfully.""" + air_conditioner.status.update_attribute_value(Attribute.switch, 'off') + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_OFF + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + + async def test_entity_and_device_attributes(hass, thermostat): """Test the attributes of the entries are correct.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 28aa759a359..b79ab59a98a 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -6,6 +6,8 @@ from aiohttp import ClientResponseError from pysmartthings import APIResponseError from homeassistant import data_entry_flow +from homeassistant.components import cloud +from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.config_flow import ( SmartThingsFlowHandler) from homeassistant.components.smartthings.const import ( @@ -41,7 +43,7 @@ async def test_base_url_not_https(hass): hass.config.api.base_url = 'http://0.0.0.0' flow = SmartThingsFlowHandler() flow.hass = hass - result = await flow.async_step_import() + result = await flow.async_step_user({'access_token': str(uuid4())}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'user' @@ -193,6 +195,38 @@ async def test_app_created_then_show_wait_form( assert result['step_id'] == 'wait_install' +async def test_cloudhook_app_created_then_show_wait_form( + hass, app, app_oauth_client, smartthings_mock): + """Test SmartApp is created with a cloudhoko and shows wait form.""" + # Unload the endpoint so we can reload it under the cloud. + await smartapp.unload_smartapp_endpoint(hass) + + mock_async_active_subscription = Mock(return_value=True) + mock_create_cloudhook = Mock(return_value=mock_coro( + return_value="http://cloud.test")) + with patch.object(cloud, 'async_active_subscription', + new=mock_async_active_subscription), \ + patch.object(cloud, 'async_create_cloudhook', + new=mock_create_cloudhook): + + await smartapp.setup_smartapp_endpoint(hass) + + flow = SmartThingsFlowHandler() + flow.hass = hass + smartthings = smartthings_mock.return_value + smartthings.apps.return_value = mock_coro(return_value=[]) + smartthings.create_app.return_value = \ + mock_coro(return_value=(app, app_oauth_client)) + smartthings.update_app_settings.return_value = mock_coro() + smartthings.update_app_oauth.return_value = mock_coro() + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + assert mock_create_cloudhook.call_count == 1 + + async def test_app_updated_then_show_wait_form( hass, app, app_oauth_client, smartthings_mock): """Test SmartApp is updated when an existing is already created.""" diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 1f648c7716a..a5edc93fce6 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -6,14 +6,15 @@ from aiohttp import ClientConnectionError, ClientResponseError from pysmartthings import InstalledAppStatus import pytest -from homeassistant.components import smartthings +from homeassistant.components import cloud, smartthings from homeassistant.components.smartthings.const import ( - CONF_INSTALLED_APP_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DOMAIN, - EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_PLATFORMS) + CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_REFRESH_TOKEN, + DATA_BROKERS, DOMAIN, EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, + SUPPORTED_PLATFORMS) from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect -from tests.common import mock_coro +from tests.common import MockConfigEntry, mock_coro async def test_migration_creates_new_flow( @@ -22,12 +23,14 @@ async def test_migration_creates_new_flow( config_entry.version = 1 setattr(hass.config_entries, '_entries', [config_entry]) api = smartthings_mock.return_value - api.delete_installed_app.return_value = mock_coro() + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() await smartthings.async_migrate_entry(hass, config_entry) + await hass.async_block_till_done() assert api.delete_installed_app.call_count == 1 - await hass.async_block_till_done() + assert api.delete_app.call_count == 1 assert not hass.config_entries.async_entries(DOMAIN) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -209,6 +212,136 @@ async def test_unload_entry(hass, config_entry): assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) +async def test_remove_entry(hass, config_entry, smartthings_mock): + """Test that the installed app and app are removed up.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_cloudhook(hass, config_entry, smartthings_mock): + """Test that the installed app, app, and cloudhook are removed up.""" + # Arrange + setattr(hass.config_entries, '_entries', [config_entry]) + hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() + mock_async_is_logged_in = Mock(return_value=True) + mock_async_delete_cloudhook = Mock(return_value=mock_coro()) + # Act + with patch.object(cloud, 'async_is_logged_in', + new=mock_async_is_logged_in), \ + patch.object(cloud, 'async_delete_cloudhook', + new=mock_async_delete_cloudhook): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + assert mock_async_is_logged_in.call_count == 1 + assert mock_async_delete_cloudhook.call_count == 1 + + +async def test_remove_entry_app_in_use(hass, config_entry, smartthings_mock): + """Test app is not removed if in use by another config entry.""" + # Arrange + data = config_entry.data.copy() + data[CONF_INSTALLED_APP_ID] = str(uuid4()) + entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) + setattr(hass.config_entries, '_entries', [config_entry, entry2]) + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_already_deleted( + hass, config_entry, smartthings_mock): + """Test handles when the apps have already been removed.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=403)) + api.delete_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=403)) + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_installedapp_api_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the installed app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=500)) + # Act + with pytest.raises(ClientResponseError): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_installedapp_unknown_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the installed app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=Exception) + # Act + with pytest.raises(Exception): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_app_api_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=500)) + # Act + with pytest.raises(ClientResponseError): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_app_unknown_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro( + exception=Exception) + # Act + with pytest.raises(Exception): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + async def test_broker_regenerates_token( hass, config_entry): """Test the device broker regenerates the refresh token.""" diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 879aae1994d..1ae9c0e9e73 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -11,7 +11,8 @@ from homeassistant.components.sensor import ( from homeassistant.components.smartthings import sensor from homeassistant.components.smartthings.const import ( DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -34,7 +35,7 @@ async def test_async_setup_platform(): async def test_entity_state(hass, device_factory): - """Tests the state attributes properly match the light types.""" + """Tests the state attributes properly match the sensor types.""" device = device_factory('Sensor 1', [Capability.battery], {Attribute.battery: 100}) await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) @@ -45,6 +46,38 @@ async def test_entity_state(hass, device_factory): device.label + " Battery" +async def test_entity_three_axis_state(hass, device_factory): + """Tests the state attributes properly match the three axis types.""" + device = device_factory('Three Axis', [Capability.three_axis], + {Attribute.three_axis: [100, 75, 25]}) + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + state = hass.states.get('sensor.three_axis_x_coordinate') + assert state.state == '100' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " X Coordinate" + state = hass.states.get('sensor.three_axis_y_coordinate') + assert state.state == '75' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " Y Coordinate" + state = hass.states.get('sensor.three_axis_z_coordinate') + assert state.state == '25' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " Z Coordinate" + + +async def test_entity_three_axis_invalid_state(hass, device_factory): + """Tests the state attributes properly match the three axis types.""" + device = device_factory('Three Axis', [Capability.three_axis], + {Attribute.three_axis: []}) + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + state = hass.states.get('sensor.three_axis_x_coordinate') + assert state.state == STATE_UNKNOWN + state = hass.states.get('sensor.three_axis_y_coordinate') + assert state.state == STATE_UNKNOWN + state = hass.states.get('sensor.three_axis_z_coordinate') + assert state.state == STATE_UNKNOWN + + async def test_entity_and_device_attributes(hass, device_factory): """Test the attributes of the entity are correct.""" # Arrange diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 798c92eddad..4cb4a291b16 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.sonos import media_player as sonos from homeassistant.components.media_player.const import DOMAIN from homeassistant.components.sonos.media_player import CONF_INTERFACE_ADDR from homeassistant.const import CONF_HOSTS, CONF_PLATFORM +from homeassistant.util.async_ import run_coroutine_threadsafe from tests.common import get_test_home_assistant @@ -328,7 +329,9 @@ class TestSonosMediaPlayer(unittest.TestCase): snapshotMock.return_value = True entity.soco.group = mock.MagicMock() entity.soco.group.members = [e.soco for e in entities] - sonos.SonosEntity.snapshot_multi(entities, True) + run_coroutine_threadsafe( + sonos.SonosEntity.snapshot_multi(self.hass, entities, True), + self.hass.loop).result() assert snapshotMock.call_count == 1 assert snapshotMock.call_args == mock.call() @@ -350,6 +353,8 @@ class TestSonosMediaPlayer(unittest.TestCase): entity._snapshot_group = mock.MagicMock() entity._snapshot_group.members = [e.soco for e in entities] entity._soco_snapshot = Snapshot(entity.soco) - sonos.SonosEntity.restore_multi(entities, True) + run_coroutine_threadsafe( + sonos.SonosEntity.restore_multi(self.hass, entities, True), + self.hass.loop).result() assert restoreMock.call_count == 1 assert restoreMock.call_args == mock.call() diff --git a/tests/components/stream/__init__.py b/tests/components/stream/__init__.py new file mode 100644 index 00000000000..96247f0ee16 --- /dev/null +++ b/tests/components/stream/__init__.py @@ -0,0 +1 @@ +"""The tests for stream platforms.""" diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py new file mode 100644 index 00000000000..7e8016cd43c --- /dev/null +++ b/tests/components/stream/common.py @@ -0,0 +1,63 @@ +"""Collection of test helpers.""" +import io + +from homeassistant.components.stream import Stream +from homeassistant.components.stream.const import ( + DOMAIN, ATTR_STREAMS) + + +def generate_h264_video(): + """ + Generate a test video. + + See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html + """ + import numpy as np + import av + + duration = 5 + fps = 24 + total_frames = duration * fps + + output = io.BytesIO() + output.name = 'test.ts' + container = av.open(output, mode='w') + + stream = container.add_stream('libx264', rate=fps) + stream.width = 480 + stream.height = 320 + stream.pix_fmt = 'yuv420p' + + for frame_i in range(total_frames): + + img = np.empty((480, 320, 3)) + img[:, :, 0] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (0 / 3 + frame_i / total_frames)) + img[:, :, 1] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (1 / 3 + frame_i / total_frames)) + img[:, :, 2] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (2 / 3 + frame_i / total_frames)) + + img = np.round(255 * img).astype(np.uint8) + img = np.clip(img, 0, 255) + + frame = av.VideoFrame.from_ndarray(img, format='rgb24') + for packet in stream.encode(frame): + container.mux(packet) + + # Flush stream + for packet in stream.encode(): + container.mux(packet) + + # Close the file + container.close() + output.seek(0) + + return output + + +def preload_stream(hass, stream_source): + """Preload a stream for use in tests.""" + stream = Stream(hass, stream_source) + hass.data[DOMAIN][ATTR_STREAMS][stream_source] = stream + return stream diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py new file mode 100644 index 00000000000..a2c962ffb45 --- /dev/null +++ b/tests/components/stream/test_hls.py @@ -0,0 +1,117 @@ +"""The tests for hls streams.""" +from datetime import timedelta +from urllib.parse import urlparse + +from homeassistant.setup import async_setup_component +from homeassistant.components.stream import request_stream +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.stream.common import ( + generate_h264_video, preload_stream) + + +async def test_hls_stream(hass, hass_client): + """ + Test hls stream. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + stream.add_provider('hls') + + # Request stream + url = request_stream(hass, source) + + http_client = await hass_client() + + # Fetch playlist + parsed_url = urlparse(url) + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Fetch segment + playlist = await playlist_response.text() + playlist_url = '/'.join(parsed_url.path.split('/')[:-1]) + segment_url = playlist_url + playlist.splitlines()[-1][1:] + segment_response = await http_client.get(segment_url) + assert segment_response.status == 200 + + # Stop stream, if it hasn't quit already + stream.stop() + + # Ensure playlist not accessable after stream ends + fail_response = await http_client.get(parsed_url.path) + assert fail_response.status == 404 + + +async def test_stream_timeout(hass, hass_client): + """Test hls stream timeout.""" + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + stream.add_provider('hls') + + # Request stream + url = request_stream(hass, source) + + http_client = await hass_client() + + # Fetch playlist + parsed_url = urlparse(url) + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Wait a minute + future = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, future) + + # Fetch again to reset timer + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Wait 5 minutes + future = dt_util.utcnow() + timedelta(minutes=5) + async_fire_time_changed(hass, future) + + # Ensure playlist not accessable + fail_response = await http_client.get(parsed_url.path) + assert fail_response.status == 404 + + +async def test_stream_ended(hass): + """Test hls stream packets ended.""" + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + track = stream.add_provider('hls') + track.num_segments = 2 + + # Request stream + request_stream(hass, source) + + # Run it dead + segments = 0 + while await track.recv() is not None: + segments += 1 + + assert segments == 3 + assert not track.get_segment() + + # Stop stream, if it hasn't quit already + stream.stop() diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 03c95fdf897..ee291439a2c 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -123,8 +123,8 @@ async def test_non_net_consumption(hass): assert state.state == '0' -async def _test_self_reset(hass, cycle, start_time, expect_reset=True): - """Test energy sensor self reset.""" +def gen_config(cycle, offset=None): + """Generate configuration.""" config = { 'utility_meter': { 'energy_bill': { @@ -134,6 +134,16 @@ async def _test_self_reset(hass, cycle, start_time, expect_reset=True): } } + if offset: + config['utility_meter']['energy_bill']['offset'] = { + 'days': offset.days, + 'seconds': offset.seconds + } + return config + + +async def _test_self_reset(hass, config, start_time, expect_reset=True): + """Test energy sensor self reset.""" assert await async_setup_component(hass, DOMAIN, config) assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() @@ -173,30 +183,50 @@ async def _test_self_reset(hass, cycle, start_time, expect_reset=True): async def test_self_reset_hourly(hass): """Test hourly reset of meter.""" - await _test_self_reset(hass, 'hourly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('hourly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_daily(hass): """Test daily reset of meter.""" - await _test_self_reset(hass, 'daily', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('daily'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_weekly(hass): """Test weekly reset of meter.""" - await _test_self_reset(hass, 'weekly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('weekly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_monthly(hass): """Test monthly reset of meter.""" - await _test_self_reset(hass, 'monthly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('monthly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_yearly(hass): """Test yearly reset of meter.""" - await _test_self_reset(hass, 'yearly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('yearly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_no_reset_yearly(hass): """Test yearly reset of meter does not occur after 1st January.""" - await _test_self_reset(hass, 'yearly', "2018-01-01T23:59:00.000000+00:00", + await _test_self_reset(hass, gen_config('yearly'), + "2018-01-01T23:59:00.000000+00:00", + expect_reset=False) + + +async def test_reset_yearly_offset(hass): + """Test yearly reset of meter.""" + await _test_self_reset(hass, + gen_config('yearly', timedelta(days=1, minutes=10)), + "2018-01-02T00:09:00.000000+00:00") + + +async def test_no_reset_yearly_offset(hass): + """Test yearly reset of meter.""" + await _test_self_reset(hass, gen_config('yearly', timedelta(31)), + "2018-01-30T23:59:00.000000+00:00", expect_reset=False) diff --git a/tests/components/websocket_api/__init__.py b/tests/components/websocket_api/__init__.py index c218c6165d4..e58197e60be 100644 --- a/tests/components/websocket_api/__init__.py +++ b/tests/components/websocket_api/__init__.py @@ -1,2 +1,2 @@ """Tests for the websocket API.""" -API_PASSWORD = 'test1234' +API_PASSWORD = 'test-password' diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 9d2d2ce251e..e2c6e303326 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -5,7 +5,6 @@ from homeassistant.components.websocket_api.const import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_INVALID, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from homeassistant.components.websocket_api import commands from homeassistant.setup import async_setup_component from tests.common import mock_coro @@ -45,7 +44,7 @@ async def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): async def test_pre_auth_only_auth_allowed(no_auth_websocket_client): """Verify that before authentication, only auth messages are allowed.""" await no_auth_websocket_client.send_json({ - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c9ec04c5d7e..4f3be31b22c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -6,7 +6,7 @@ from homeassistant.components.websocket_api.const import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED ) -from homeassistant.components.websocket_api import const, commands +from homeassistant.components.websocket_api import const from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -27,7 +27,7 @@ async def test_call_service(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -52,7 +52,7 @@ async def test_call_service_not_found(hass, websocket_client): """Test call service command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -83,7 +83,7 @@ async def test_call_service_error(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'ha_error', }) @@ -98,7 +98,7 @@ async def test_call_service_error(hass, websocket_client): await websocket_client.send_json({ 'id': 6, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'unknown_error', }) @@ -118,7 +118,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'type': 'subscribe_events', 'event_type': 'test_event' }) @@ -138,7 +138,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): msg = await websocket_client.receive_json() assert msg['id'] == 5 - assert msg['type'] == commands.TYPE_EVENT + assert msg['type'] == 'event' event = msg['event'] assert event['event_type'] == 'test_event' @@ -147,7 +147,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): await websocket_client.send_json({ 'id': 6, - 'type': commands.TYPE_UNSUBSCRIBE_EVENTS, + 'type': 'unsubscribe_events', 'subscription': 5 }) @@ -167,7 +167,7 @@ async def test_get_states(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() @@ -189,7 +189,7 @@ async def test_get_services(hass, websocket_client): """Test get_services command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_SERVICES, + 'type': 'get_services', }) msg = await websocket_client.receive_json() @@ -203,7 +203,7 @@ async def test_get_config(hass, websocket_client): """Test get_config command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_CONFIG, + 'type': 'get_config', }) msg = await websocket_client.receive_json() @@ -224,12 +224,12 @@ async def test_ping(websocket_client): """Test get_panels command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_PING, + 'type': 'ping', }) msg = await websocket_client.receive_json() assert msg['id'] == 5 - assert msg['type'] == commands.TYPE_PONG + assert msg['type'] == 'pong' async def test_call_service_context_with_user(hass, aiohttp_client, @@ -258,7 +258,7 @@ async def test_call_service_context_with_user(hass, aiohttp_client, await ws.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -285,7 +285,7 @@ async def test_subscribe_requires_admin(websocket_client, hass_admin_user): hass_admin_user.groups = [] await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'type': 'subscribe_events', 'event_type': 'test_event' }) @@ -307,7 +307,7 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): hass.states.async_set('test.not_visible_entity', 'invisible') await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() @@ -327,9 +327,82 @@ async def test_get_states_not_allows_nan(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() assert not msg['success'] assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR + + +async def test_subscribe_unsubscribe_events_whitelist( + hass, websocket_client, hass_admin_user): + """Test subscribe/unsubscribe events on whitelist.""" + hass_admin_user.groups = [] + + await websocket_client.send_json({ + 'id': 5, + 'type': 'subscribe_events', + 'event_type': 'not-in-whitelist' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == 'unauthorized' + + await websocket_client.send_json({ + 'id': 6, + 'type': 'subscribe_events', + 'event_type': 'themes_updated' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 6 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + + hass.bus.async_fire('themes_updated') + + with timeout(3, loop=hass.loop): + msg = await websocket_client.receive_json() + + assert msg['id'] == 6 + assert msg['type'] == 'event' + event = msg['event'] + assert event['event_type'] == 'themes_updated' + assert event['origin'] == 'LOCAL' + + +async def test_subscribe_unsubscribe_events_state_changed( + hass, websocket_client, hass_admin_user): + """Test subscribe/unsubscribe state_changed events.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'light.permitted': True + } + } + }) + + await websocket_client.send_json({ + 'id': 7, + 'type': 'subscribe_events', + 'event_type': 'state_changed' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 7 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + + hass.states.async_set('light.not_permitted', 'on') + hass.states.async_set('light.permitted', 'on') + + msg = await websocket_client.receive_json() + assert msg['id'] == 7 + assert msg['type'] == 'event' + assert msg['event']['event_type'] == 'state_changed' + assert msg['event']['data']['entity_id'] == 'light.permitted' diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index a7e54e8146a..272ac3870ed 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import patch, Mock from aiohttp import WSMsgType import pytest -from homeassistant.components.websocket_api import const, commands, messages +from homeassistant.components.websocket_api import const, messages @pytest.fixture @@ -56,7 +56,7 @@ def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): for idx in range(10): yield from websocket_client.send_json({ 'id': idx + 1, - 'type': commands.TYPE_PING, + 'type': 'ping', }) msg = yield from websocket_client.receive() assert msg.type == WSMsgType.close diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index bd594941da1..de05c89bbb0 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -10,6 +10,7 @@ from homeassistant.components.zha.core.gateway import establish_device_mappings from homeassistant.components.zha.core.channels.registry \ import populate_channel_registry from .common import async_setup_entry +from homeassistant.components.zha.core.store import async_get_registry @pytest.fixture(name='config_entry') @@ -22,7 +23,7 @@ def config_entry_fixture(hass): @pytest.fixture(name='zha_gateway') -def zha_gateway_fixture(hass): +async def zha_gateway_fixture(hass): """Fixture representing a zha gateway. Create a ZHAGateway object that can be used to interact with as if we @@ -34,7 +35,8 @@ def zha_gateway_fixture(hass): hass.data[DATA_ZHA][component] = ( hass.data[DATA_ZHA].get(component, {}) ) - return ZHAGateway(hass, {}) + zha_storage = await async_get_registry(hass) + return ZHAGateway(hass, {}, zha_storage) @pytest.fixture(autouse=True) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 616a94e8b89..5858c7560d9 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -3,9 +3,7 @@ from unittest.mock import Mock import pytest from homeassistant.components.switch import DOMAIN from homeassistant.components.zha.api import ( - async_load_api, WS_DEVICE_CLUSTERS, ATTR_IEEE, TYPE, - ID, WS_DEVICE_CLUSTER_ATTRIBUTES, WS_DEVICE_CLUSTER_COMMANDS, - WS_DEVICES + async_load_api, ATTR_IEEE, TYPE, ID ) from homeassistant.components.zha.core.const import ( ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, IN, IEEE, MODEL, NAME, QUIRK_APPLIED, @@ -38,7 +36,7 @@ async def test_device_clusters(hass, config_entry, zha_gateway, zha_client): """Test getting device cluster info.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTERS, + TYPE: 'zha/devices/clusters', ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7' }) @@ -64,7 +62,7 @@ async def test_device_cluster_attributes( """Test getting device cluster attributes.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTER_ATTRIBUTES, + TYPE: 'zha/devices/clusters/attributes', ATTR_ENDPOINT_ID: 1, ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7', ATTR_CLUSTER_ID: 6, @@ -86,7 +84,7 @@ async def test_device_cluster_commands( """Test getting device cluster commands.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTER_COMMANDS, + TYPE: 'zha/devices/clusters/commands', ATTR_ENDPOINT_ID: 1, ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7', ATTR_CLUSTER_ID: 6, @@ -109,7 +107,7 @@ async def test_list_devices( """Test getting entity cluster commands.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICES + TYPE: 'zha/devices' }) msg = await zha_client.receive_json() diff --git a/tests/conftest.py b/tests/conftest.py index 1dc5733cf40..efe24c51533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,10 +153,12 @@ def legacy_auth(hass): """Load legacy API password provider.""" prv = legacy_api_password.LegacyApiPasswordAuthProvider( hass, hass.auth._store, { - 'type': 'legacy_api_password' + 'type': 'legacy_api_password', + 'api_password': 'test-password', } ) hass.auth._providers[(prv.type, prv.id)] = prv + return prv @pytest.fixture @@ -168,6 +170,7 @@ def local_auth(hass): } ) hass.auth._providers[(prv.type, prv.id)] = prv + return prv @pytest.fixture diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 9f2801fe334..284cb2b3dbe 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,4 +1,7 @@ """Tests for the Area Registry.""" +import asyncio + +import asynctest import pytest from homeassistant.helpers import area_registry @@ -125,3 +128,17 @@ async def test_loading_area_from_storage(hass, hass_storage): registry = await area_registry.async_get_registry(hass) assert len(registry.areas) == 1 + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.area_registry.AreaRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + area_registry.async_get_registry(hass), + area_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index caf1dafdf8f..adfa05a021b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,6 +1,8 @@ """Tests for the Device Registry.""" +import asyncio from unittest.mock import patch +import asynctest import pytest from homeassistant.helpers import device_registry @@ -370,3 +372,17 @@ async def test_update(registry): assert updated_entry != entry assert updated_entry.area_id == '12345A' assert updated_entry.name_by_user == 'Test Friendly Name' + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.device_registry.DeviceRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + device_registry.async_get_registry(hass), + device_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 27e33a4fe7d..163261a4b81 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -206,7 +206,7 @@ def test_extract_from_service_available_device(hass): assert ['test_domain.test_1', 'test_domain.test_3'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call_1)) + (yield from component.async_extract_from_service(call_1))) call_2 = ha.ServiceCall('test', 'service', data={ 'entity_id': ['test_domain.test_3', 'test_domain.test_4'], @@ -214,7 +214,7 @@ def test_extract_from_service_available_device(hass): assert ['test_domain.test_3'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call_2)) + (yield from component.async_extract_from_service(call_2))) @asyncio.coroutine @@ -275,7 +275,7 @@ def test_extract_from_service_returns_all_if_no_entity_id(hass): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + (yield from component.async_extract_from_service(call))) @asyncio.coroutine @@ -293,7 +293,7 @@ def test_extract_from_service_filter_out_non_existing_entities(hass): assert ['test_domain.test_2'] == \ [ent.entity_id for ent - in component.async_extract_from_service(call)] + in (yield from component.async_extract_from_service(call))] @asyncio.coroutine @@ -308,7 +308,8 @@ def test_extract_from_service_no_group_expand(hass): 'entity_id': ['group.test_group'] }) - extracted = component.async_extract_from_service(call, expand_group=False) + extracted = yield from component.async_extract_from_service( + call, expand_group=False) assert extracted == [test_group] @@ -466,7 +467,7 @@ async def test_extract_all_omit_entity_id(hass, caplog): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + await component.async_extract_from_service(call)) assert ('Not passing an entity ID to a service to target all entities is ' 'deprecated') in caplog.text @@ -483,6 +484,6 @@ async def test_extract_all_use_match_all(hass, caplog): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + await component.async_extract_from_service(call)) assert ('Not passing an entity ID to a service to target all entities is ' 'deprecated') not in caplog.text diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b1c13a36c6d..3fb79f693bd 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -2,6 +2,7 @@ import asyncio from unittest.mock import patch +import asynctest import pytest from homeassistant.core import valid_entity_id @@ -19,7 +20,6 @@ def registry(hass): return mock_registry(hass) -@asyncio.coroutine def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -30,7 +30,6 @@ def test_get_or_create_returns_same_entry(registry): assert entry.entity_id == 'light.hue_1234' -@asyncio.coroutine def test_get_or_create_suggested_object_id(registry): """Test that suggested_object_id works.""" entry = registry.async_get_or_create( @@ -39,7 +38,6 @@ def test_get_or_create_suggested_object_id(registry): assert entry.entity_id == 'light.beer' -@asyncio.coroutine def test_get_or_create_suggested_object_id_conflict_register(registry): """Test that we don't generate an entity id that is already registered.""" entry = registry.async_get_or_create( @@ -51,7 +49,6 @@ def test_get_or_create_suggested_object_id_conflict_register(registry): assert entry2.entity_id == 'light.beer_2' -@asyncio.coroutine def test_get_or_create_suggested_object_id_conflict_existing(hass, registry): """Test that we don't generate an entity id that currently exists.""" hass.states.async_set('light.hue_1234', 'on') @@ -59,7 +56,6 @@ def test_get_or_create_suggested_object_id_conflict_existing(hass, registry): assert entry.entity_id == 'light.hue_1234_2' -@asyncio.coroutine def test_create_triggers_save(hass, registry): """Test that registering entry triggers a save.""" with patch.object(registry, 'async_schedule_save') as mock_schedule_save: @@ -91,7 +87,6 @@ async def test_loading_saving_data(hass, registry): assert orig_entry2 == new_entry2 -@asyncio.coroutine def test_generate_entity_considers_registered_entities(registry): """Test that we don't create entity id that are already registered.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -100,7 +95,6 @@ def test_generate_entity_considers_registered_entities(registry): 'light.hue_1234_2' -@asyncio.coroutine def test_generate_entity_considers_existing_entities(hass, registry): """Test that we don't create entity id that currently exists.""" hass.states.async_set('light.kitchen', 'on') @@ -108,7 +102,6 @@ def test_generate_entity_considers_existing_entities(hass, registry): 'light.kitchen_2' -@asyncio.coroutine def test_is_registered(registry): """Test that is_registered works.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -166,7 +159,6 @@ async def test_loading_extra_values(hass, hass_storage): assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER -@asyncio.coroutine def test_async_get_entity_id(registry): """Test that entity_id is returned.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -176,7 +168,7 @@ def test_async_get_entity_id(registry): assert registry.async_get_entity_id('light', 'hue', '123') is None -async def test_updating_config_entry_id(registry): +def test_updating_config_entry_id(registry): """Test that we update config entry id in registry.""" entry = registry.async_get_or_create( 'light', 'hue', '5678', config_entry_id='mock-id-1') @@ -186,7 +178,7 @@ async def test_updating_config_entry_id(registry): assert entry2.config_entry_id == 'mock-id-2' -async def test_removing_config_entry_id(registry): +def test_removing_config_entry_id(registry): """Test that we update config entry id in registry.""" entry = registry.async_get_or_create( 'light', 'hue', '5678', config_entry_id='mock-id-1') @@ -265,3 +257,17 @@ async def test_loading_invalid_entity_id(hass, hass_storage): 'test', 'super_platform', 'id-invalid-start') assert valid_entity_id(entity_invalid_start.entity_id) + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.entity_registry.EntityRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + entity_registry.async_get_registry(hass), + entity_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 35e89fc5218..a36785b6ba0 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -5,18 +5,21 @@ from copy import deepcopy import unittest from unittest.mock import Mock, patch +import voluptuous as vol import pytest # To prevent circular import when running just this file import homeassistant.components # noqa from homeassistant import core as ha, loader, exceptions from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID -from homeassistant.helpers import service, template from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv from homeassistant.auth.permissions import PolicyPermissions - -from tests.common import get_test_home_assistant, mock_service, mock_coro +from homeassistant.helpers import ( + service, template, device_registry as dev_reg, entity_registry as ent_reg) +from tests.common import ( + get_test_home_assistant, mock_service, mock_coro, mock_registry, + mock_device_registry) @pytest.fixture @@ -163,29 +166,83 @@ class TestServiceHelpers(unittest.TestCase): }) assert 3 == mock_log.call_count - def test_extract_entity_ids(self): - """Test extract_entity_ids method.""" - self.hass.states.set('light.Bowl', STATE_ON) - self.hass.states.set('light.Ceiling', STATE_OFF) - self.hass.states.set('light.Kitchen', STATE_OFF) - loader.get_component(self.hass, 'group').Group.create_group( - self.hass, 'test', ['light.Ceiling', 'light.Kitchen']) +async def test_extract_entity_ids(hass): + """Test extract_entity_ids method.""" + hass.states.async_set('light.Bowl', STATE_ON) + hass.states.async_set('light.Ceiling', STATE_OFF) + hass.states.async_set('light.Kitchen', STATE_OFF) - call = ha.ServiceCall('light', 'turn_on', - {ATTR_ENTITY_ID: 'light.Bowl'}) + await loader.get_component(hass, 'group').Group.async_create_group( + hass, 'test', ['light.Ceiling', 'light.Kitchen']) - assert ['light.bowl'] == \ - service.extract_entity_ids(self.hass, call) + call = ha.ServiceCall('light', 'turn_on', + {ATTR_ENTITY_ID: 'light.Bowl'}) - call = ha.ServiceCall('light', 'turn_on', - {ATTR_ENTITY_ID: 'group.test'}) + assert {'light.bowl'} == \ + await service.async_extract_entity_ids(hass, call) - assert ['light.ceiling', 'light.kitchen'] == \ - service.extract_entity_ids(self.hass, call) + call = ha.ServiceCall('light', 'turn_on', + {ATTR_ENTITY_ID: 'group.test'}) - assert ['group.test'] == service.extract_entity_ids( - self.hass, call, expand_group=False) + assert {'light.ceiling', 'light.kitchen'} == \ + await service.async_extract_entity_ids(hass, call) + + assert {'group.test'} == await service.async_extract_entity_ids( + hass, call, expand_group=False) + + +async def test_extract_entity_ids_from_area(hass): + """Test extract_entity_ids method with areas.""" + hass.states.async_set('light.Bowl', STATE_ON) + hass.states.async_set('light.Ceiling', STATE_OFF) + hass.states.async_set('light.Kitchen', STATE_OFF) + + device_in_area = dev_reg.DeviceEntry(area_id='test-area') + device_no_area = dev_reg.DeviceEntry() + device_diff_area = dev_reg.DeviceEntry(area_id='diff-area') + + mock_device_registry(hass, { + device_in_area.id: device_in_area, + device_no_area.id: device_no_area, + device_diff_area.id: device_diff_area, + }) + + entity_in_area = ent_reg.RegistryEntry( + entity_id='light.in_area', + unique_id='in-area-id', + platform='test', + device_id=device_in_area.id, + ) + entity_no_area = ent_reg.RegistryEntry( + entity_id='light.no_area', + unique_id='no-area-id', + platform='test', + device_id=device_no_area.id, + ) + entity_diff_area = ent_reg.RegistryEntry( + entity_id='light.diff_area', + unique_id='diff-area-id', + platform='test', + device_id=device_diff_area.id, + ) + mock_registry(hass, { + entity_in_area.entity_id: entity_in_area, + entity_no_area.entity_id: entity_no_area, + entity_diff_area.entity_id: entity_diff_area, + }) + + call = ha.ServiceCall('light', 'turn_on', + {'area_id': 'test-area'}) + + assert {'light.in_area'} == \ + await service.async_extract_entity_ids(hass, call) + + call = ha.ServiceCall('light', 'turn_on', + {'area_id': ['test-area', 'diff-area']}) + + assert {'light.in_area', 'light.diff_area'} == \ + await service.async_extract_entity_ids(hass, call) @asyncio.coroutine @@ -338,3 +395,37 @@ async def test_call_with_omit_entity_id(hass, mock_service_platform_call, mock_entities['light.kitchen'], mock_entities['light.living_room']] assert ('Not passing an entity ID to a service to target ' 'all entities is deprecated') in caplog.text + + +async def test_register_admin_service(hass, hass_read_only_user, + hass_admin_user): + """Test the register admin service.""" + calls = [] + + async def mock_service(call): + calls.append(call) + + hass.helpers.service.async_register_admin_service( + 'test', 'test', mock_service, vol.Schema({}) + ) + + with pytest.raises(exceptions.UnknownUser): + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id='non-existing' + )) + assert len(calls) == 0 + + with pytest.raises(exceptions.Unauthorized): + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id=hass_read_only_user.id + )) + assert len(calls) == 0 + + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id=hass_admin_user.id + )) + assert len(calls) == 1 + assert calls[0].context.user_id == hass_admin_user.id diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 978b0b9d450..1b62c5244e4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -34,7 +34,7 @@ def test_from_config_file(hass): } with patch_yaml_files(files, True): - yield from bootstrap.async_from_config_file('config.yaml') + yield from bootstrap.async_from_config_file('config.yaml', hass) assert components == hass.config.components @@ -103,3 +103,12 @@ async def test_async_from_config_file_not_mount_deps_folder(loop): await bootstrap.async_from_config_file('mock-path', hass) assert len(mock_mount.mock_calls) == 0 + + +async def test_load_hassio(hass): + """Test that we load Hass.io component.""" + with patch.dict(os.environ, {}, clear=True): + assert bootstrap._get_components(hass, {}) == set() + + with patch.dict(os.environ, {'HASSIO': '1'}): + assert bootstrap._get_components(hass, {}) == {'hassio'} diff --git a/tests/test_config.py b/tests/test_config.py index e860ff53b3d..8afad09c946 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -822,7 +822,7 @@ async def test_auth_provider_config(hass): 'time_zone': 'GMT', CONF_AUTH_PROVIDERS: [ {'type': 'homeassistant'}, - {'type': 'legacy_api_password'}, + {'type': 'legacy_api_password', 'api_password': 'some-pass'}, ], CONF_AUTH_MFA_MODULES: [ {'type': 'totp'}, @@ -873,11 +873,12 @@ async def test_auth_provider_config_default_api_password(hass): } if hasattr(hass, 'auth'): del hass.auth - await config_util.async_process_ha_core_config(hass, core_config, True) + await config_util.async_process_ha_core_config(hass, core_config, 'pass') assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' + assert hass.auth.auth_providers[1].api_password == 'pass' async def test_auth_provider_config_default_trusted_networks(hass): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8991035cc22..324db971583 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -173,6 +173,9 @@ async def test_remove_entry(hass, manager): assert result return result + mock_remove_entry = MagicMock( + side_effect=lambda *args, **kwargs: mock_coro()) + entity = MockEntity( unique_id='1234', name='Test Entity', @@ -185,7 +188,8 @@ async def test_remove_entry(hass, manager): loader.set_component(hass, 'test', MockModule( 'test', async_setup_entry=mock_setup_entry, - async_unload_entry=mock_unload_entry + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry )) loader.set_component( hass, 'light.test', @@ -227,6 +231,9 @@ async def test_remove_entry(hass, manager): 'require_restart': False } + # Check the remove callback was invoked. + assert mock_remove_entry.call_count == 1 + # Check that config entry was removed. assert [item.entry_id for item in manager.async_entries()] == \ ['test1', 'test3'] @@ -241,6 +248,43 @@ async def test_remove_entry(hass, manager): assert entity_entry.config_entry_id is None +async def test_remove_entry_handles_callback_error(hass, manager): + """Test that exceptions in the remove callback are handled.""" + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_unload_entry = MagicMock(return_value=mock_coro(True)) + mock_remove_entry = MagicMock( + side_effect=lambda *args, **kwargs: mock_coro()) + loader.set_component(hass, 'test', MockModule( + 'test', + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry + )) + entry = MockConfigEntry( + domain='test', + entry_id='test1', + ) + entry.add_to_manager(manager) + # Check all config entries exist + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1'] + # Setup entry + await entry.async_setup(hass) + await hass.async_block_till_done() + + # Remove entry + result = await manager.async_remove('test1') + await hass.async_block_till_done() + # Check that unload went well and so no need to restart + assert result == { + 'require_restart': False + } + # Check the remove callback was invoked. + assert mock_remove_entry.call_count == 1 + # Check that config entry was removed. + assert [item.entry_id for item in manager.async_entries()] == [] + + @asyncio.coroutine def test_remove_entry_raises(hass, manager): """Test if a component raises while removing entry.""" @@ -407,7 +451,7 @@ async def test_saving_and_loading(hass): # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) - await manager.async_load() + await manager.async_initialize() # Ensure same order for orig, loaded in zip(hass.config_entries.async_entries(), @@ -518,7 +562,7 @@ async def test_loading_default_config(hass): manager = config_entries.ConfigEntries(hass, {}) with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): - await manager.async_load() + await manager.async_initialize() assert len(manager.async_entries()) == 0 @@ -650,3 +694,219 @@ async def test_entry_options(hass, manager): assert entry.options == { 'second': True } + + +async def test_entry_setup_succeed(hass, manager): + """Test that we can setup an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_setup = MagicMock(return_value=mock_coro(True)) + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry + )) + + assert await manager.async_setup(entry.entry_id) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_setup_invalid_state(hass, manager, state): + """Test that we cannot setup an entry with invalid state.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + mock_setup = MagicMock(return_value=mock_coro(True)) + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_setup(entry.entry_id) + + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + assert entry.state == state + + +async def test_entry_unload_succeed(hass, manager): + """Test that we can unload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_LOADED + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + assert await manager.async_unload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_NOT_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, +)) +async def test_entry_unload_failed_to_load(hass, manager, state): + """Test that we can unload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state, + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + assert await manager.async_unload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 0 + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_unload_invalid_state(hass, manager, state): + """Test that we cannot unload an entry with invalid state.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_unload(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert entry.state == state + + +async def test_entry_reload_succeed(hass, manager): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_LOADED + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + assert await manager.async_reload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 1 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_NOT_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, +)) +async def test_entry_reload_not_loaded(hass, manager, state): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + assert await manager.async_reload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_reload_error(hass, manager, state): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_reload(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + + assert entry.state == state diff --git a/tests/test_core.py b/tests/test_core.py index ef9621bdac7..cdcf30fa8b3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -310,6 +310,7 @@ class TestEvent(unittest.TestCase): 'time_fired': now, 'context': { 'id': event.context.id, + 'parent_id': None, 'user_id': event.context.user_id, }, } @@ -1114,3 +1115,16 @@ async def test_service_call_event_contains_original_data(hass): assert len(calls) == 1 assert calls[0].data['number'] == 23 assert calls[0].context is context + + +def test_context(): + """Test context init.""" + c = ha.Context() + assert c.user_id is None + assert c.parent_id is None + assert c.id is not None + + c = ha.Context(23, 100) + assert c.user_id == 23 + assert c.parent_id == 100 + assert c.id is not None diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 3e57ea20b5c..8baacec5dca 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -1,5 +1,6 @@ """Tests for async util methods from Python source.""" import asyncio +import sys from unittest.mock import MagicMock, patch from unittest import TestCase @@ -144,7 +145,11 @@ class RunThreadsafeTests(TestCase): """Wait 0.05 second and return a + b.""" yield from asyncio.sleep(0.05, loop=self.loop) if cancel: - asyncio.tasks.Task.current_task(self.loop).cancel() + if sys.version_info[:2] >= (3, 7): + current_task = asyncio.current_task + else: + current_task = asyncio.tasks.Task.current_task + current_task(self.loop).cancel() yield return self.add_callback(a, b, fail, invalid) @@ -205,7 +210,11 @@ class RunThreadsafeTests(TestCase): self.loop.run_until_complete(future) self.run_briefly(self.loop) # Check that there's no pending task (add has been cancelled) - for task in asyncio.Task.all_tasks(self.loop): + if sys.version_info[:2] >= (3, 7): + all_tasks = asyncio.all_tasks + else: + all_tasks = asyncio.Task.all_tasks + for task in all_tasks(self.loop): self.assertTrue(task.done()) def test_run_coroutine_threadsafe_task_cancelled(self): diff --git a/tox.ini b/tox.ini index 1dfa77c14f1..8423141df60 100644 --- a/tox.ini +++ b/tox.ini @@ -3,40 +3,23 @@ envlist = py35, py36, py37, py38, lint, pylint, typing, cov skip_missing_interpreters = True [testenv] -setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant -; both temper-python and XBee modules have utf8 in their README files -; which get read in from setup.py. If we don't force our locale to a -; utf8 one, tox's env is reset. And the install of these 2 packages -; fail. -whitelist_externals = /usr/bin/env -install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} +basepython = {env:PYTHON3_PATH:python3} commands = - pytest --timeout=9 --duration=10 {posargs} + pytest --timeout=9 --duration=10 -qq -o console_output_style=count -p no:sugar {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt -c{toxinidir}/homeassistant/package_constraints.txt [testenv:cov] -basepython = {env:PYTHON3_PATH:python3} -setenv = - PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant -; both temper-python and XBee modules have utf8 in their README files -; which get read in from setup.py. If we don't force our locale to a -; utf8 one, tox's env is reset. And the install of these 2 packages -; fail. -whitelist_externals = /usr/bin/env -install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = - pytest --timeout=9 --duration=10 --cov --cov-report= {posargs} + pytest --timeout=9 --duration=10 -qq -o console_output_style=count -p no:sugar --cov --cov-report= {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt -c{toxinidir}/homeassistant/package_constraints.txt [testenv:pylint] -basepython = {env:PYTHON3_PATH:python3} ignore_errors = True deps = -r{toxinidir}/requirements_all.txt @@ -46,18 +29,17 @@ commands = pylint {posargs} homeassistant [testenv:lint] -basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = - python script/gen_requirements_all.py validate - flake8 {posargs} - pydocstyle {posargs:homeassistant tests} + python script/gen_requirements_all.py validate + flake8 {posargs} + pydocstyle {posargs:homeassistant tests} [testenv:typing] -basepython = {env:PYTHON3_PATH:python3} whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt + -c{toxinidir}/homeassistant/package_constraints.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,aiohttp_client,area_registry,condition,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,aiohttp_client,area_registry,condition,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 713bbfffba4..bbc513502fa 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -28,6 +28,9 @@ PACKAGES=( libmpc-dev libmpfr-dev libgmp-dev # homeassistant.components.ffmpeg ffmpeg + # homeassistant.components.stream + libavformat-dev libavcodec-dev libavdevice-dev + libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # homeassistant.components.sensor.iperf3 iperf3 )