diff --git a/.coveragerc b/.coveragerc index b64699f685f..2a6446092e5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -120,6 +120,9 @@ omit = homeassistant/components/eufy.py homeassistant/components/*/eufy.py + homeassistant/components/fibaro.py + homeassistant/components/*/fibaro.py + homeassistant/components/gc100.py homeassistant/components/*/gc100.py @@ -203,6 +206,9 @@ omit = homeassistant/components/logi_circle.py homeassistant/components/*/logi_circle.py + homeassistant/components/lupusec.py + homeassistant/components/*/lupusec.py + homeassistant/components/lutron.py homeassistant/components/*/lutron.py @@ -256,6 +262,10 @@ omit = homeassistant/components/pilight.py homeassistant/components/*/pilight.py + homeassistant/components/point/__init__.py + homeassistant/components/point/const.py + homeassistant/components/*/point.py + homeassistant/components/switch/qwikswitch.py homeassistant/components/light/qwikswitch.py @@ -265,7 +275,7 @@ omit = homeassistant/components/raincloud.py homeassistant/components/*/raincloud.py - homeassistant/components/rainmachine/* + homeassistant/components/rainmachine/__init__.py homeassistant/components/*/rainmachine.py homeassistant/components/raspihats.py @@ -333,6 +343,9 @@ omit = homeassistant/components/toon.py homeassistant/components/*/toon.py + homeassistant/components/tplink_lte.py + homeassistant/components/*/tplink_lte.py + homeassistant/components/tradfri.py homeassistant/components/*/tradfri.py @@ -365,6 +378,9 @@ omit = homeassistant/components/*/webostv.py + homeassistant/components/w800rf32.py + homeassistant/components/*/w800rf32.py + homeassistant/components/wemo.py homeassistant/components/*/wemo.py @@ -474,6 +490,7 @@ omit = homeassistant/components/device_tracker/freebox.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py + homeassistant/components/device_tracker/googlehome.py homeassistant/components/device_tracker/gpslogger.py homeassistant/components/device_tracker/hitron_coda.py homeassistant/components/device_tracker/huawei_router.py @@ -496,6 +513,7 @@ omit = homeassistant/components/device_tracker/tile.py homeassistant/components/device_tracker/tomato.py homeassistant/components/device_tracker/tplink.py + homeassistant/components/device_tracker/traccar.py homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubus.py homeassistant/components/downloader.py @@ -530,6 +548,7 @@ omit = homeassistant/components/light/lw12wifi.py homeassistant/components/light/mystrom.py homeassistant/components/light/nanoleaf_aurora.py + homeassistant/components/light/niko_home_control.py homeassistant/components/light/opple.py homeassistant/components/light/osramlightify.py homeassistant/components/light/piglow.py @@ -572,7 +591,7 @@ omit = homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py - homeassistant/components/media_player/lg_soundbar.py + homeassistant/components/media_player/lg_soundbar.py homeassistant/components/media_player/liveboxplaytv.py homeassistant/components/media_player/mediaroom.py homeassistant/components/media_player/mpchc.py @@ -581,6 +600,7 @@ omit = homeassistant/components/media_player/nadtcp.py homeassistant/components/media_player/onkyo.py homeassistant/components/media_player/openhome.py + homeassistant/components/media_player/panasonic_bluray.py homeassistant/components/media_player/panasonic_viera.py homeassistant/components/media_player/pandora.py homeassistant/components/media_player/philips_js.py @@ -696,6 +716,7 @@ omit = homeassistant/components/sensor/fints.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py + homeassistant/components/sensor/flunearyou.py homeassistant/components/sensor/folder.py homeassistant/components/sensor/foobot.py homeassistant/components/sensor/fritzbox_callmonitor.py @@ -720,6 +741,7 @@ omit = homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lacrosse.py homeassistant/components/sensor/lastfm.py + homeassistant/components/sensor/launch_library.py homeassistant/components/sensor/linky.py homeassistant/components/sensor/linux_battery.py homeassistant/components/sensor/loopenergy.py @@ -763,10 +785,12 @@ omit = homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py homeassistant/components/sensor/rtorrent.py + homeassistant/components/sensor/ruter.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sensehat.py homeassistant/components/sensor/serial_pm.py homeassistant/components/sensor/serial.py + homeassistant/components/sensor/seventeentrack.py homeassistant/components/sensor/sht31.py homeassistant/components/sensor/shodan.py homeassistant/components/sensor/sigfox.py @@ -786,9 +810,11 @@ omit = homeassistant/components/sensor/swiss_public_transport.py homeassistant/components/sensor/syncthru.py homeassistant/components/sensor/synologydsm.py + homeassistant/components/sensor/srp_energy.py homeassistant/components/sensor/systemmonitor.py homeassistant/components/sensor/sytadin.py homeassistant/components/sensor/tank_utility.py + homeassistant/components/sensor/tautulli.py homeassistant/components/sensor/ted5000.py homeassistant/components/sensor/temper.py homeassistant/components/sensor/thermoworks_smoke.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1e37cf86fc3..3bc284627fc 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,6 +13,7 @@ ## Checklist: - [ ] The code change is tested and works locally. - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** + - [ ] There is no commented out code in this PR. If user exposed functionality or configuration variables are added/changed: - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) diff --git a/CODEOWNERS b/CODEOWNERS index bf4c342b474..dabc3bbd4db 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -102,6 +102,7 @@ homeassistant/components/sensor/darksky.py @fabaff homeassistant/components/sensor/file.py @fabaff homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/fixer.py @fabaff +homeassistant/components/sensor/flunearyou.py.py @bachya homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/gitter.py @fabaff homeassistant/components/sensor/glances.py @fabaff @@ -109,7 +110,6 @@ homeassistant/components/sensor/gpsd.py @fabaff homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/jewish_calendar.py @tsvi homeassistant/components/sensor/linux_battery.py @fabaff -homeassistant/components/sensor/luftdaten.py @fabaff homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff homeassistant/components/sensor/moon.py @fabaff @@ -121,6 +121,7 @@ homeassistant/components/sensor/pvoutput.py @fabaff homeassistant/components/sensor/qnap.py @colinodell homeassistant/components/sensor/scrape.py @fabaff homeassistant/components/sensor/serial.py @fabaff +homeassistant/components/sensor/seventeentrack.py @bachya homeassistant/components/sensor/shodan.py @fabaff homeassistant/components/sensor/sma.py @kellerza homeassistant/components/sensor/sql.py @dgomes @@ -189,6 +190,8 @@ homeassistant/components/*/konnected.py @heythisisnate # L homeassistant/components/lifx.py @amelchio homeassistant/components/*/lifx.py @amelchio +homeassistant/components/luftdaten/* @fabaff +homeassistant/components/*/luftdaten.py @fabaff # M homeassistant/components/matrix.py @tinloaf diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9fd9bf3fa50..7d8ef13d2bb 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -13,6 +13,7 @@ from homeassistant.core import callback, HomeAssistant from homeassistant.util import dt as dt_util from . import auth_store, models +from .const import GROUP_ID_ADMIN from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule from .providers import auth_provider_from_config, AuthProvider, LoginFlow @@ -117,6 +118,10 @@ class AuthManager: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all groups.""" + return await self._store.async_get_group(group_id) + async def async_get_user_by_credentials( self, credentials: models.Credentials) -> Optional[models.User]: """Get a user by credential, return None if not found.""" @@ -127,13 +132,15 @@ class AuthManager: return None - async def async_create_system_user(self, name: str) -> models.User: + async def async_create_system_user( + self, name: str, + group_ids: Optional[List[str]] = None) -> models.User: """Create a system user.""" user = await self._store.async_create_user( name=name, system_generated=True, is_active=True, - groups=[], + group_ids=group_ids or [], ) self.hass.bus.async_fire(EVENT_USER_ADDED, { @@ -144,11 +151,10 @@ class AuthManager: async def async_create_user(self, name: str) -> models.User: """Create a user.""" - group = (await self._store.async_get_groups())[0] kwargs = { 'name': name, 'is_active': True, - 'groups': [group] + 'group_ids': [GROUP_ID_ADMIN] } # type: Dict[str, Any] if await self._user_should_be_owner(): @@ -213,6 +219,17 @@ class AuthManager: 'user_id': user.id }) + async def async_update_user(self, user: models.User, + name: Optional[str] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + kwargs = {} # type: Dict[str,Any] + if name is not None: + kwargs['name'] = name + if group_ids is not None: + kwargs['group_ids'] = group_ids + await self._store.async_update_user(user, **kwargs) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 8c328bfe13e..cf82c40a4d3 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -10,11 +10,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models -from .permissions import DEFAULT_POLICY +from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from .permissions import system_policies +from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 STORAGE_KEY = 'auth' -INITIAL_GROUP_NAME = 'All Access' +GROUP_NAME_ADMIN = 'Administrators' +GROUP_NAME_READ_ONLY = 'Read Only' class AuthStore: @@ -42,6 +45,14 @@ class AuthStore: return list(self._groups.values()) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return self._groups.get(group_id) + async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" if self._users is None: @@ -63,7 +74,7 @@ class AuthStore: is_active: Optional[bool] = None, system_generated: Optional[bool] = None, credentials: Optional[models.Credentials] = None, - groups: Optional[List[models.Group]] = None) -> models.User: + group_ids: Optional[List[str]] = None) -> models.User: """Create a new user.""" if self._users is None: await self._async_load() @@ -71,11 +82,18 @@ class AuthStore: assert self._users is not None assert self._groups is not None + groups = [] + for group_id in (group_ids or []): + group = self._groups.get(group_id) + if group is None: + raise ValueError('Invalid group specified {}'.format(group_id)) + groups.append(group) + kwargs = { 'name': name, # Until we get group management, we just put everyone in the # same group. - 'groups': groups or [], + 'groups': groups, } # type: Dict[str, Any] if is_owner is not None: @@ -115,6 +133,33 @@ class AuthStore: self._users.pop(user.id) self._async_schedule_save() + async def async_update_user( + self, user: models.User, name: Optional[str] = None, + is_active: Optional[bool] = None, + group_ids: Optional[List[str]] = None) -> None: + """Update a user.""" + assert self._groups is not None + + if group_ids is not None: + groups = [] + for grid in group_ids: + group = self._groups.get(grid) + if group is None: + raise ValueError("Invalid group specified.") + groups.append(group) + + user.groups = groups + user.invalidate_permission_cache() + + for attr_name, value in ( + ('name', name), + ('is_active', is_active), + ): + if value is not None: + setattr(user, attr_name, value) + + self._async_schedule_save() + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" user.is_active = True @@ -238,38 +283,98 @@ class AuthStore: users = OrderedDict() # type: Dict[str, models.User] groups = OrderedDict() # type: Dict[str, models.Group] - # When creating objects we mention each attribute explicetely. This + # Soft-migrating data as we load. We are going to make sure we have a + # read only group and an admin group. There are two states that we can + # migrate from: + # 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_read_only_group = False + group_without_policy = None + + # When creating objects we mention each attribute explicitly. This # prevents crashing if user rolls back HA version after a new property # was added. for group_dict in data.get('groups', []): + policy = None # type: Optional[PolicyType] + + if group_dict['id'] == GROUP_ID_ADMIN: + has_admin_group = True + + name = GROUP_NAME_ADMIN + policy = system_policies.ADMIN_POLICY + system_generated = True + + elif group_dict['id'] == GROUP_ID_READ_ONLY: + has_read_only_group = True + + name = GROUP_NAME_READ_ONLY + policy = system_policies.READ_ONLY_POLICY + system_generated = True + + else: + name = group_dict['name'] + policy = group_dict.get('policy') + system_generated = False + + # We don't want groups without a policy that are not system groups + # This is part of migrating from state 1 + if policy is None: + group_without_policy = group_dict['id'] + continue + groups[group_dict['id']] = models.Group( - name=group_dict['name'], id=group_dict['id'], - policy=group_dict.get('policy', DEFAULT_POLICY), + name=name, + policy=policy, + system_generated=system_generated, ) - migrate_group = None + # If there are no groups, add all existing users to the admin group. + # This is part of migrating from state 2 + migrate_users_to_admin_group = (not groups and + group_without_policy is None) - if not groups: - migrate_group = models.Group( - name=INITIAL_GROUP_NAME, - policy=DEFAULT_POLICY - ) - groups[migrate_group.id] = migrate_group + # If we find a no_policy_group, we need to migrate all users to the + # admin group. We only do this if there are no other groups, as is + # the expected state. If not expected state, not marking people admin. + # This is part of migrating from state 1 + if groups and group_without_policy is not None: + group_without_policy = None + + # This is part of migrating from state 1 and 2 + if not has_admin_group: + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + + # This is part of migrating from state 1 and 2 + if not has_read_only_group: + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group for user_dict in data['users']: + # Collect the users group. + user_groups = [] + for group_id in user_dict.get('group_ids', []): + # This is part of migrating from state 1 + if group_id == group_without_policy: + group_id = GROUP_ID_ADMIN + user_groups.append(groups[group_id]) + + # This is part of migrating from state 2 + if (not user_dict['system_generated'] and + migrate_users_to_admin_group): + user_groups.append(groups[GROUP_ID_ADMIN]) + users[user_dict['id']] = models.User( name=user_dict['name'], - groups=[groups[group_id] for group_id - in user_dict.get('group_ids', [])], + groups=user_groups, id=user_dict['id'], is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], ) - if migrate_group is not None and not user_dict['system_generated']: - users[user_dict['id']].groups = [migrate_group] for cred_dict in data['credentials']: users[cred_dict['user_id']].credentials.append(models.Credentials( @@ -356,11 +461,11 @@ class AuthStore: groups = [] for group in self._groups.values(): g_dict = { - 'name': group.name, 'id': group.id, } # type: Dict[str, Any] - if group.policy is not DEFAULT_POLICY: + if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): + g_dict['name'] = group.name g_dict['policy'] = group.policy groups.append(g_dict) @@ -410,13 +515,29 @@ class AuthStore: """Set default values for auth store.""" self._users = OrderedDict() # type: Dict[str, models.User] - # Add default group - all_access_group = models.Group( - name=INITIAL_GROUP_NAME, - policy=DEFAULT_POLICY, - ) - groups = OrderedDict() # type: Dict[str, models.Group] - groups[all_access_group.id] = all_access_group - + admin_group = _system_admin_group() + groups[admin_group.id] = admin_group + read_only_group = _system_read_only_group() + groups[read_only_group.id] = read_only_group self._groups = groups + + +def _system_admin_group() -> models.Group: + """Create system admin group.""" + return models.Group( + name=GROUP_NAME_ADMIN, + id=GROUP_ID_ADMIN, + policy=system_policies.ADMIN_POLICY, + system_generated=True, + ) + + +def _system_read_only_group() -> models.Group: + """Create read only group.""" + return models.Group( + name=GROUP_NAME_READ_ONLY, + id=GROUP_ID_READ_ONLY, + policy=system_policies.READ_ONLY_POLICY, + system_generated=True, + ) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 2e57986958c..519669ead85 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -3,3 +3,6 @@ from datetime import timedelta ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) + +GROUP_ID_ADMIN = 'system-admin' +GROUP_ID_READ_ONLY = 'system-read-only' diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index fc35f1398db..4b192c35898 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -8,6 +8,7 @@ import attr from homeassistant.util import dt as dt_util from . import permissions as perm_mdl +from .const import GROUP_ID_ADMIN from .util import generate_secret TOKEN_TYPE_NORMAL = 'normal' @@ -22,6 +23,7 @@ class Group: name = attr.ib(type=str) # type: Optional[str] policy = attr.ib(type=perm_mdl.PolicyType) id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + system_generated = attr.ib(type=bool, default=False) @attr.s(slots=True) @@ -47,7 +49,7 @@ class User: ) # type: Dict[str, RefreshToken] _permissions = attr.ib( - type=perm_mdl.PolicyPermissions, + type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None, @@ -68,6 +70,19 @@ class User: return self._permissions + @property + def is_admin(self) -> bool: + """Return if user is part of the admin group.""" + if self.is_owner: + return True + + return self.is_active and any( + gr.id == GROUP_ID_ADMIN for gr in self.groups) + + def invalidate_permission_cache(self) -> None: + """Invalidate permission cache.""" + self._permissions = None + @attr.s(slots=True) class RefreshToken: diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index ee0d3af0c54..9113f2b03a9 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -5,20 +5,11 @@ from typing import ( # noqa: F401 import voluptuous as vol -from homeassistant.core import State - -from .common import CategoryType, PolicyType +from .const import CAT_ENTITIES +from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa - -# Default policy if group has no policy applied. -DEFAULT_POLICY = { - "entities": True -} # type: PolicyType - -CAT_ENTITIES = 'entities' - POLICY_SCHEMA = vol.Schema({ vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA }) @@ -29,13 +20,20 @@ _LOGGER = logging.getLogger(__name__) class AbstractPermissions: """Default permissions class.""" - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" + _cached_entity_func = None + + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" raise NotImplementedError - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - raise NotImplementedError + def check_entity(self, entity_id: str, key: str) -> bool: + """Check if we can access entity.""" + entity_func = self._cached_entity_func + + if entity_func is None: + entity_func = self._cached_entity_func = self._entity_func() + + return entity_func(entity_id, key) class PolicyPermissions(AbstractPermissions): @@ -44,34 +42,10 @@ class PolicyPermissions(AbstractPermissions): def __init__(self, policy: PolicyType) -> None: """Initialize the permission class.""" self._policy = policy - self._compiled = {} # type: Dict[str, Callable[..., bool]] - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - return func(entity_id, (key,)) - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - func = self._policy_func(CAT_ENTITIES, compile_entities) - keys = ('read',) - return [entity for entity in states if func(entity.entity_id, keys)] - - def _policy_func(self, category: str, - compile_func: Callable[[CategoryType], Callable]) \ - -> Callable[..., bool]: - """Get a policy function.""" - func = self._compiled.get(category) - - if func: - return func - - func = self._compiled[category] = compile_func( - self._policy.get(category)) - - _LOGGER.debug("Compiled %s func: %s", category, func) - - return func + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return compile_entities(self._policy.get(CAT_ENTITIES)) def __eq__(self, other: Any) -> bool: """Equals check.""" @@ -85,13 +59,9 @@ class _OwnerPermissions(AbstractPermissions): # pylint: disable=no-self-use - def check_entity(self, entity_id: str, key: str) -> bool: - """Test if we can access entity.""" - return True - - def filter_states(self, states: List[State]) -> List[State]: - """Filter a list of states for what the user is allowed to see.""" - return states + def _entity_func(self) -> Callable[[str, str], bool]: + """Return a function that can test entity access.""" + return lambda entity_id, key: True OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py new file mode 100644 index 00000000000..e60879881c1 --- /dev/null +++ b/homeassistant/auth/permissions/const.py @@ -0,0 +1,7 @@ +"""Permission constants.""" +CAT_ENTITIES = 'entities' +SUBCAT_ALL = 'all' + +POLICY_READ = 'read' +POLICY_CONTROL = 'control' +POLICY_EDIT = 'edit' diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index b38600fe130..74a43246fd1 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -5,12 +5,8 @@ from typing import ( # noqa: F401 import voluptuous as vol -from .common import CategoryType, ValueType, SUBCAT_ALL - - -POLICY_READ = 'read' -POLICY_CONTROL = 'control' -POLICY_EDIT = 'edit' +from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .types import CategoryType, ValueType SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(POLICY_READ): True, @@ -32,28 +28,28 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ })) -def _entity_allowed(schema: ValueType, keys: Tuple[str]) \ +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(keys[0]) + return schema.get(key) def compile_entities(policy: CategoryType) \ - -> Callable[[str, Tuple[str]], bool]: + -> 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, keys: Tuple[str]) -> bool: + 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, keys: Tuple[str]) -> bool: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: """Approve all.""" return True @@ -65,7 +61,7 @@ def compile_entities(policy: CategoryType) \ entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) - funcs = [] # type: List[Callable[[str, Tuple[str]], Union[None, bool]]] + 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. @@ -74,23 +70,23 @@ def compile_entities(policy: CategoryType) \ # 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, keys: Tuple[str]) -> 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, keys: Tuple[str]) \ + 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), keys) # type: ignore + entity_ids.get(entity_id), key) # type: ignore funcs.append(allowed_entity_id_dict) if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, keys: Tuple[str]) \ + def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" return domains @@ -98,31 +94,31 @@ def compile_entities(policy: CategoryType) \ funcs.append(allowed_domain_bool) elif domains is not None: - def allowed_domain_dict(entity_id: str, keys: Tuple[str]) \ + 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), keys) # type: ignore + 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, keys: Tuple[str]) \ + 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, keys: Tuple[str]) \ + def allowed_all_entities_dict(entity_id: str, key: str) \ -> Union[None, bool]: """Test if allowed domain.""" - return _entity_allowed(all_entities, keys) + 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, keys: Tuple[str]) -> bool: + def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: """Decline all.""" return False @@ -132,16 +128,16 @@ def compile_entities(policy: CategoryType) \ func = funcs[0] @wraps(func) - def apply_policy_func(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_func(entity_id: str, key: str) -> bool: """Apply a single policy function.""" - return func(entity_id, keys) is True + return func(entity_id, key) is True return apply_policy_func - def apply_policy_funcs(entity_id: str, keys: Tuple[str]) -> bool: + def apply_policy_funcs(entity_id: str, key: str) -> bool: """Apply several policy functions.""" for func in funcs: - result = func(entity_id, keys) + result = func(entity_id, key) if result is not None: return result return False diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py index 32cbfefcf1c..ec6375a0e3d 100644 --- a/homeassistant/auth/permissions/merge.py +++ b/homeassistant/auth/permissions/merge.py @@ -2,7 +2,7 @@ from typing import ( # noqa: F401 cast, Dict, List, Set) -from .common import PolicyType, CategoryType +from .types import PolicyType, CategoryType def merge_policies(policies: List[PolicyType]) -> PolicyType: diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py new file mode 100644 index 00000000000..78da68c0d11 --- /dev/null +++ b/homeassistant/auth/permissions/system_policies.py @@ -0,0 +1,14 @@ +"""System policies.""" +from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ + +ADMIN_POLICY = { + CAT_ENTITIES: True, +} + +READ_ONLY_POLICY = { + CAT_ENTITIES: { + SUBCAT_ALL: { + POLICY_READ: True + } + } +} diff --git a/homeassistant/auth/permissions/common.py b/homeassistant/auth/permissions/types.py similarity index 97% rename from homeassistant/auth/permissions/common.py rename to homeassistant/auth/permissions/types.py index f87f9d70ddf..1871861f291 100644 --- a/homeassistant/auth/permissions/common.py +++ b/homeassistant/auth/permissions/types.py @@ -29,5 +29,3 @@ CategoryType = Union[ # Example: { entities: … } PolicyType = Mapping[str, CategoryType] - -SUBCAT_ALL = 'all' diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index a3fbe49477e..fb4dccc1c86 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -13,9 +13,10 @@ from homeassistant.const import ( CONF_PENDING_TIME, CONF_TRIGGER_TIME) -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 Demo alarm control panel platform.""" - add_entities([ + async_add_entities([ manual.ManualAlarm(hass, 'Alarm', '1234', None, False, { STATE_ALARM_ARMED_AWAY: { CONF_DELAY_TIME: datetime.timedelta(seconds=0), diff --git a/homeassistant/components/alarm_control_panel/ialarm.py b/homeassistant/components/alarm_control_panel/ialarm.py index 3f41ee57902..efc7436e21b 100644 --- a/homeassistant/components/alarm_control_panel/ialarm.py +++ b/homeassistant/components/alarm_control_panel/ialarm.py @@ -12,10 +12,10 @@ import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) + STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyialarm==0.2'] +REQUIREMENTS = ['pyialarm==0.3'] _LOGGER = logging.getLogger(__name__) @@ -89,6 +89,8 @@ class IAlarmPanel(alarm.AlarmControlPanel): state = STATE_ALARM_ARMED_AWAY elif status == self._client.ARMED_STAY: state = STATE_ALARM_ARMED_HOME + elif status == self._client.TRIGGERED: + state = STATE_ALARM_TRIGGERED else: state = None diff --git a/homeassistant/components/alarm_control_panel/lupusec.py b/homeassistant/components/alarm_control_panel/lupusec.py new file mode 100644 index 00000000000..44d8a068ce2 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/lupusec.py @@ -0,0 +1,67 @@ +""" +This component provides HA alarm_control_panel support for Lupusec System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.lupusec/ +""" + +from datetime import timedelta + +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.lupusec import DOMAIN as LUPUSEC_DOMAIN +from homeassistant.components.lupusec import LupusecDevice +from homeassistant.const import (STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED) + +DEPENDENCIES = ['lupusec'] + +ICON = 'mdi:security' + +SCAN_INTERVAL = timedelta(seconds=2) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up an alarm control panel for a Lupusec device.""" + if discovery_info is None: + return + + data = hass.data[LUPUSEC_DOMAIN] + + alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())] + + add_entities(alarm_devices) + + +class LupusecAlarm(LupusecDevice, AlarmControlPanel): + """An alarm_control_panel implementation for Lupusec.""" + + @property + def icon(self): + """Return the icon.""" + return ICON + + @property + def state(self): + """Return the state of the device.""" + if self._device.is_standby: + state = STATE_ALARM_DISARMED + elif self._device.is_away: + state = STATE_ALARM_ARMED_AWAY + elif self._device.is_home: + state = STATE_ALARM_ARMED_HOME + else: + state = None + return state + + def alarm_arm_away(self, code=None): + """Send arm away command.""" + self._device.set_away() + + def alarm_disarm(self, code=None): + """Send disarm command.""" + self._device.set_standby() + + def alarm_arm_home(self, code=None): + """Send arm home command.""" + self._device.set_home() diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 834a502baa0..fc59ac4d088 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -335,11 +335,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): return state_attr - def async_added_to_hass(self): - """Subscribe to MQTT events. - - This method must be run in the event loop and returns a coroutine. - """ + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" async_track_state_change( self.hass, self.entity_id, self._async_state_changed_listener ) @@ -359,7 +356,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): _LOGGER.warning("Received unexpected payload: %s", payload) return - return mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._command_topic, message_received, self._qos) async def _async_state_changed_listener(self, entity_id, old_state, diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 2989bb1be37..97f46cb0dfd 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.20'] +REQUIREMENTS = ['total_connect_client==0.22'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 6b747689057..2a61533a2b9 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -16,9 +16,9 @@ from homeassistant.components import ( input_boolean, light, lock, media_player, scene, script, sensor, switch) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, SERVICE_LOCK, - SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, - SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, + ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, + CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_SET, STATE_LOCKED, STATE_ON, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -474,6 +474,26 @@ class _AlexaColorController(_AlexaInterface): def name(self): return 'Alexa.ColorController' + def properties_supported(self): + return [{'name': 'color'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'color': + raise _UnsupportedProperty(name) + + hue, saturation = self.entity.attributes.get( + light.ATTR_HS_COLOR, (0, 0)) + + return { + 'hue': hue, + 'saturation': saturation / 100.0, + 'brightness': self.entity.attributes.get( + light.ATTR_BRIGHTNESS, 0) / 255.0, + } + class _AlexaColorTemperatureController(_AlexaInterface): """Implements Alexa.ColorTemperatureController. @@ -717,6 +737,9 @@ class _ClimateCapabilities(_AlexaEntity): return [_DisplayCategory.THERMOSTAT] def interfaces(self): + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if supported & climate.SUPPORT_ON_OFF: + yield _AlexaPowerController(self.entity) yield _AlexaThermostatController(self.hass, self.entity) yield _AlexaTemperatureSensor(self.hass, self.entity) @@ -1194,6 +1217,11 @@ async def async_api_discovery(hass, config, directive, context): discovery_endpoints = [] for entity in hass.states.async_all(): + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + _LOGGER.debug("Not exposing %s because it is never exposed", + entity.entity_id) + continue + if not config.should_expose(entity.entity_id): _LOGGER.debug("Not exposing %s because filtered by config", entity.entity_id) @@ -1205,7 +1233,7 @@ async def async_api_discovery(hass, config, directive, context): endpoint = { 'displayCategories': alexa_entity.display_categories(), - 'additionalApplianceDetails': {}, + 'cookie': {}, 'endpointId': alexa_entity.entity_id(), 'friendlyName': alexa_entity.friendly_name(), 'description': alexa_entity.description(), diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index cbe404537eb..b001bcd0437 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -20,7 +20,8 @@ from homeassistant.const import ( URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__) import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -81,6 +82,8 @@ class APIEventStream(HomeAssistantView): async def get(self, request): """Provide a streaming interface for the event bus.""" + if not request['hass_user'].is_admin: + raise Unauthorized() hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -185,7 +188,13 @@ class APIStatesView(HomeAssistantView): @ha.callback def get(self, request): """Get current states.""" - return self.json(request.app['hass'].states.async_all()) + user = request['hass_user'] + entity_perm = user.permissions.check_entity + states = [ + state for state in request.app['hass'].states.async_all() + if entity_perm(state.entity_id, 'read') + ] + return self.json(states) class APIEntityStateView(HomeAssistantView): @@ -197,6 +206,10 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def get(self, request, entity_id): """Retrieve state of entity.""" + user = request['hass_user'] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + state = request.app['hass'].states.get(entity_id) if state: return self.json(state) @@ -204,6 +217,8 @@ class APIEntityStateView(HomeAssistantView): async def post(self, request, entity_id): """Update state of entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) hass = request.app['hass'] try: data = await request.json() @@ -236,6 +251,8 @@ class APIEntityStateView(HomeAssistantView): @ha.callback def delete(self, request, entity_id): """Remove entity.""" + if not request['hass_user'].is_admin: + raise Unauthorized(entity_id=entity_id) if request.app['hass'].states.async_remove(entity_id): return self.json_message("Entity removed.") return self.json_message("Entity not found.", HTTP_NOT_FOUND) @@ -261,6 +278,8 @@ class APIEventView(HomeAssistantView): async def post(self, request, event_type): """Fire events.""" + if not request['hass_user'].is_admin: + raise Unauthorized() body = await request.text() try: event_data = json.loads(body) if body else None @@ -346,6 +365,8 @@ class APITemplateView(HomeAssistantView): async def post(self, request): """Render a template.""" + if not request['hass_user'].is_admin: + raise Unauthorized() try: data = await request.json() tpl = template.Template(data['template'], request.app['hass']) @@ -363,6 +384,8 @@ class APIErrorLog(HomeAssistantView): async def get(self, request): """Retrieve API error log.""" + if not request['hass_user'].is_admin: + raise Unauthorized() return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) diff --git a/homeassistant/components/asuswrt.py b/homeassistant/components/asuswrt.py new file mode 100644 index 00000000000..c653c1d03fd --- /dev/null +++ b/homeassistant/components/asuswrt.py @@ -0,0 +1,68 @@ +""" +Support for ASUSWRT devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/asuswrt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, + CONF_PROTOCOL) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['aioasuswrt==1.1.11'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "asuswrt" +DATA_ASUSWRT = DOMAIN + +CONF_PUB_KEY = 'pub_key' +CONF_SSH_KEY = 'ssh_key' +CONF_REQUIRE_IP = 'require_ip' +DEFAULT_SSH_PORT = 22 +SECRET_GROUP = 'Password or SSH Key' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), + vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), + vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, + vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, + vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, + vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, + vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the asuswrt component.""" + from aioasuswrt.asuswrt import AsusWrt + conf = config[DOMAIN] + + api = AsusWrt(conf[CONF_HOST], conf.get(CONF_PORT), + conf.get(CONF_PROTOCOL) == 'telnet', + conf[CONF_USERNAME], + conf.get(CONF_PASSWORD, ''), + conf.get('ssh_key', conf.get('pub_key', '')), + conf.get(CONF_MODE), conf.get(CONF_REQUIRE_IP)) + + await api.connection.async_connect() + if not api.is_connected: + _LOGGER.error("Unable to setup asuswrt component") + return False + + hass.data[DATA_ASUSWRT] = api + + hass.async_create_task(async_load_platform( + hass, 'sensor', DOMAIN, {}, config)) + hass.async_create_task(async_load_platform( + hass, 'device_tracker', DOMAIN, {}, config)) + return True diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index ce8e3d8de11..1f12abd3d4e 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,8 +11,9 @@ import voluptuous as vol from requests import RequestException import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback from homeassistant.const import ( - CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT) + CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery from homeassistant.util import Throttle @@ -20,7 +21,7 @@ _LOGGER = logging.getLogger(__name__) _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.6.0'] +REQUIREMENTS = ['py-august==0.7.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 @@ -116,7 +117,8 @@ def setup_august(hass, config, api, authenticator): if DOMAIN in _CONFIGURING: hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) - hass.data[DATA_AUGUST] = AugustData(api, authentication.access_token) + hass.data[DATA_AUGUST] = AugustData( + hass, api, authentication.access_token) for component in AUGUST_COMPONENTS: discovery.load_platform(hass, component, DOMAIN, {}, config) @@ -136,9 +138,16 @@ def setup(hass, config): """Set up the August component.""" from august.api import Api from august.authenticator import Authenticator + from requests import Session conf = config[DOMAIN] - api = Api(timeout=conf.get(CONF_TIMEOUT)) + try: + api_http_session = Session() + except RequestException as ex: + _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + api_http_session = None + + api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) authenticator = Authenticator( api, @@ -154,8 +163,9 @@ def setup(hass, config): class AugustData: """August data object.""" - def __init__(self, api, access_token): + def __init__(self, hass, api, access_token): """Init August data object.""" + self._hass = hass self._api = api self._access_token = access_token self._doorbells = self._api.get_doorbells(self._access_token) or [] @@ -168,6 +178,22 @@ class AugustData: self._door_state_by_id = {} self._activities_by_id = {} + @callback + def august_api_stop(event): + """Close the API HTTP session.""" + _LOGGER.debug("Closing August HTTP session") + + try: + self._api.http_session.close() + self._api.http_session = None + except RequestException: + pass + _LOGGER.debug("August HTTP session closed.") + + self._hass.bus.listen_once( + EVENT_HOMEASSISTANT_STOP, august_api_stop) + _LOGGER.debug("Registered for HASS stop event") + @property def house_ids(self): """Return a list of house_ids.""" @@ -201,8 +227,11 @@ class AugustData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" - _LOGGER.debug("Updating device activities") + _LOGGER.debug("Start retrieving device activities") for house_id in self.house_ids: + _LOGGER.debug("Updating device activity for house id %s", + house_id) + activities = self._api.get_house_activities(self._access_token, house_id, limit=limit) @@ -211,6 +240,7 @@ class AugustData: for device_id in device_ids: self._activities_by_id[device_id] = [a for a in activities if a.device_id == device_id] + _LOGGER.debug("Completed retrieving device activities") def get_doorbell_detail(self, doorbell_id): """Return doorbell detail.""" @@ -223,7 +253,7 @@ class AugustData: _LOGGER.debug("Start retrieving doorbell details") for doorbell in self._doorbells: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name) try: detail_by_id[doorbell.device_id] =\ @@ -267,7 +297,7 @@ class AugustData: _LOGGER.debug("Start retrieving door status") for lock in self._locks: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating door status for %s", lock.device_name) try: @@ -291,7 +321,7 @@ class AugustData: _LOGGER.debug("Start retrieving locks status") for lock in self._locks: - _LOGGER.debug("Updating status for %s", + _LOGGER.debug("Updating lock status for %s", lock.device_name) try: status_by_id[lock.device_id] = self._api.get_lock_status( diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json new file mode 100644 index 00000000000..508ffac6739 --- /dev/null +++ b/homeassistant/components/auth/.translations/cs.json @@ -0,0 +1,26 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici." + }, + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu." + }, + "step": { + "init": { + "description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:", + "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" + }, + "setup": { + "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" + } + } + }, + "totp": { + "error": { + "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json new file mode 100644 index 00000000000..bfec5cd9274 --- /dev/null +++ b/homeassistant/components/auth/.translations/es.json @@ -0,0 +1,35 @@ +{ + "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "No hay servicios de notificaci\u00f3n disponibles." + }, + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo." + }, + "step": { + "init": { + "description": "Seleccione uno de los servicios de notificaci\u00f3n:", + "title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n" + }, + "setup": { + "description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notificar. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:", + "title": "Verificar la configuraci\u00f3n" + } + }, + "title": "Notificar la contrase\u00f1a de un solo uso" + }, + "totp": { + "error": { + "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, por favor aseg\u00farate de que el reloj de tu Home Assistant es correcto." + }, + "step": { + "init": { + "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.", + "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP" + } + }, + "title": "TOTP" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/it.json b/homeassistant/components/auth/.translations/it.json index 869c3b438af..25dad4c1aeb 100644 --- a/homeassistant/components/auth/.translations/it.json +++ b/homeassistant/components/auth/.translations/it.json @@ -1,6 +1,27 @@ { "mfa_setup": { + "notify": { + "abort": { + "no_available_service": "Nessun servizio di notifica disponibile." + }, + "error": { + "invalid_code": "Codice non valido, per favore riprovare." + }, + "step": { + "init": { + "description": "Selezionare uno dei servizi di notifica:" + }, + "setup": { + "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", + "title": "Verifica l'installazione" + } + }, + "title": "Notifica la Password monouso" + }, "totp": { + "error": { + "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato." + }, "step": { "init": { "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.", diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index a1f1563f5e1..f8563071fbc 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -400,6 +400,9 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): This method is a coroutine. """ removes = [] + info = { + 'name': name + } for conf in trigger_configs: platform = await async_prepare_setup_platform( @@ -408,7 +411,7 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): if platform is None: return None - remove = await platform.async_trigger(hass, conf, action) + remove = await platform.async_trigger(hass, conf, action, info) if not remove: _LOGGER.error("Error setting up trigger %s", name) diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py index a9605f343fd..ec47479eac8 100644 --- a/homeassistant/components/automation/event.py +++ b/homeassistant/components/automation/event.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event_type = config.get(CONF_EVENT_TYPE) event_data_schema = vol.Schema( diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py index b2c9a9c093a..537646fefc1 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -33,7 +33,7 @@ def source_match(state, source): return state and state.attributes.get('source') == source -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" source = config.get(CONF_SOURCE).lower() zone_entity_id = config.get(CONF_ZONE) diff --git a/homeassistant/components/automation/homeassistant.py b/homeassistant/components/automation/homeassistant.py index 30ab979d6f4..6d7a44291c9 100644 --- a/homeassistant/components/automation/homeassistant.py +++ b/homeassistant/components/automation/homeassistant.py @@ -22,7 +22,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py index c0d2dd99ba2..70e01174078 100644 --- a/homeassistant/components/automation/litejet.py +++ b/homeassistant/components/automation/litejet.py @@ -32,7 +32,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" number = config.get(CONF_NUMBER) held_more_than = config.get(CONF_HELD_MORE_THAN) diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 99d5ab8674c..67c538154e5 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" topic = config.get(CONF_TOPIC) payload = config.get(CONF_PAYLOAD) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index 675b6f3653a..aa51e631026 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ _LOGGER = logging.getLogger(__name__) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) below = config.get(CONF_BELOW) diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py index 46c5cafa071..4e47026d8d1 100644 --- a/homeassistant/components/automation/state.py +++ b/homeassistant/components/automation/state.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ }), cv.key_dependency(CONF_FOR, CONF_TO)) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) from_state = config.get(CONF_FROM, MATCH_ALL) diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py index 7cefe6953a1..509195689a1 100644 --- a/homeassistant/components/automation/sun.py +++ b/homeassistant/components/automation/sun.py @@ -24,7 +24,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for events based on configuration.""" event = config.get(CONF_EVENT) offset = config.get(CONF_OFFSET) diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py index c0d83b1067f..347b3f94e7d 100644 --- a/homeassistant/components/automation/template.py +++ b/homeassistant/components/automation/template.py @@ -22,7 +22,7 @@ TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" value_template = config.get(CONF_VALUE_TEMPLATE) value_template.hass = hass diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py index eccc31581a0..116bfbdbc97 100644 --- a/homeassistant/components/automation/time.py +++ b/homeassistant/components/automation/time.py @@ -28,7 +28,7 @@ TRIGGER_SCHEMA = vol.All(vol.Schema({ }), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS, CONF_AT)) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" if CONF_AT in config: at_time = config.get(CONF_AT) diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py index 345b0fe3249..f4afc8a601a 100644 --- a/homeassistant/components/automation/webhook.py +++ b/homeassistant/components/automation/webhook.py @@ -14,6 +14,8 @@ from homeassistant.core import callback from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID import homeassistant.helpers.config_validation as cv +from . import DOMAIN as AUTOMATION_DOMAIN + DEPENDENCIES = ('webhook',) _LOGGER = logging.getLogger(__name__) @@ -39,10 +41,11 @@ async def _handle_webhook(action, hass, webhook_id, request): hass.async_run_job(action, {'trigger': result}) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Trigger based on incoming webhooks.""" webhook_id = config.get(CONF_WEBHOOK_ID) hass.components.webhook.async_register( + AUTOMATION_DOMAIN, automation_info['name'], webhook_id, partial(_handle_webhook, action)) @callback diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index dfc9cc418bf..0c3c0941a9e 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -26,7 +26,7 @@ TRIGGER_SCHEMA = vol.Schema({ }) -async def async_trigger(hass, config, action): +async def async_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" entity_id = config.get(CONF_ENTITY_ID) zone_entity_id = config.get(CONF_ZONE) diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index fe00402ec95..b9fdb08e068 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -6,8 +6,8 @@ https://home-assistant.io/components/binary_sensor.deconz/ """ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DECONZ_DOMAIN) + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, + DOMAIN as DECONZ_DOMAIN) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -24,6 +24,8 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ binary sensor.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_sensor(sensors): """Add binary sensor from deCONZ.""" @@ -33,30 +35,35 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor in sensors: if sensor.type in DECONZ_BINARY_SENSOR and \ not (not allow_clip_sensor and sensor.type.startswith('CLIP')): - entities.append(DeconzBinarySensor(sensor)) + entities.append(DeconzBinarySensor(sensor, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - async_add_sensor(hass.data[DATA_DECONZ].api.sensors.values()) + async_add_sensor(gateway.api.sensors.values()) class DeconzBinarySensor(BinarySensorDevice): """Representation of a binary sensor.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Set up sensor and add update callback to get data from websocket.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -101,7 +108,7 @@ class DeconzBinarySensor(BinarySensorDevice): @property def available(self): """Return True if sensor is available.""" - return self._sensor.reachable + return self.gateway.available and self._sensor.reachable @property def should_poll(self): @@ -128,7 +135,7 @@ class DeconzBinarySensor(BinarySensorDevice): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/binary_sensor/fibaro.py b/homeassistant/components/binary_sensor/fibaro.py new file mode 100644 index 00000000000..124ff88a9a3 --- /dev/null +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -0,0 +1,74 @@ +""" +Support for Fibaro binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.fibaro/ +""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, ENTITY_ID_FORMAT) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPES = { + 'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'], + 'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'], + 'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'], + 'com.fibaro.FGMS001': ['Motion', 'mdi:run', 'motion'], + 'com.fibaro.heatDetector': ['Heat', 'mdi:fire', 'heat'], +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroBinarySensor(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True) + + +class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): + """Representation of a Fibaro Binary Sensor.""" + + def __init__(self, fibaro_device, controller): + """Initialize the binary_sensor.""" + self._state = None + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + stype = None + if fibaro_device.type in SENSOR_TYPES: + stype = fibaro_device.type + elif fibaro_device.baseType in SENSOR_TYPES: + stype = fibaro_device.baseType + if stype: + self._device_class = SENSOR_TYPES[stype][2] + self._icon = SENSOR_TYPES[stype][1] + else: + self._device_class = None + self._icon = None + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + def update(self): + """Get the latest data and update the state.""" + self._state = self.current_binary_state diff --git a/homeassistant/components/binary_sensor/insteon.py b/homeassistant/components/binary_sensor/insteon.py index c399d31a95b..009de676bf3 100644 --- a/homeassistant/components/binary_sensor/insteon.py +++ b/homeassistant/components/binary_sensor/insteon.py @@ -57,7 +57,8 @@ class InsteonBinarySensor(InsteonEntity, BinarySensorDevice): """Return the boolean response if the node is on.""" on_val = bool(self._insteon_device_state.value) - if self._insteon_device_state.name == 'lightSensor': + if self._insteon_device_state.name in ['lightSensor', + 'openClosedSensor']: return not on_val return on_val diff --git a/homeassistant/components/binary_sensor/lupusec.py b/homeassistant/components/binary_sensor/lupusec.py new file mode 100644 index 00000000000..df8210df026 --- /dev/null +++ b/homeassistant/components/binary_sensor/lupusec.py @@ -0,0 +1,53 @@ +""" +This component provides HA binary_sensor support for Lupusec Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.lupusec/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.lupusec import (LupusecDevice, + DOMAIN as LUPUSEC_DOMAIN) +from homeassistant.components.binary_sensor import (BinarySensorDevice, + DEVICE_CLASSES) + +DEPENDENCIES = ['lupusec'] + +SCAN_INTERVAL = timedelta(seconds=2) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a sensor for an Lupusec device.""" + if discovery_info is None: + return + + import lupupy.constants as CONST + + data = hass.data[LUPUSEC_DOMAIN] + + device_types = [CONST.TYPE_OPENING] + + devices = [] + for device in data.lupusec.get_devices(generic_type=device_types): + devices.append(LupusecBinarySensor(data, device)) + + add_entities(devices) + + +class LupusecBinarySensor(LupusecDevice, BinarySensorDevice): + """A binary sensor implementation for Lupusec device.""" + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._device.is_on + + @property + def device_class(self): + """Return the class of the binary sensor.""" + if self._device.generic_type not in DEVICE_CLASSES: + return None + return self._device.generic_type diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index 7f164ae48d7..f7bd353f3d1 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -19,7 +18,8 @@ from homeassistant.const import ( from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -79,21 +79,8 @@ async def _async_setup_entity(hass, config, async_add_entities, value_template.hass = hass async_add_entities([MqttBinarySensor( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_DEVICE_CLASS), - config.get(CONF_QOS), - config.get(CONF_FORCE_UPDATE), - config.get(CONF_OFF_DELAY), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - value_template, - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash, + config, + discovery_hash )]) @@ -101,35 +88,71 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, BinarySensorDevice): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, name, state_topic, availability_topic, device_class, - qos, force_update, off_delay, payload_on, payload_off, - payload_available, payload_not_available, value_template, - unique_id: Optional[str], device_config: Optional[ConfigType], - discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the MQTT binary sensor.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) - self._name = name + self._config = config self._state = None - self._state_topic = state_topic - self._device_class = device_class - self._payload_on = payload_on - self._payload_off = payload_off - self._qos = qos - self._force_update = force_update - self._off_delay = off_delay - self._template = value_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash + self._sub_state = None self._delay_listener = None + self._name = None + self._state_topic = None + self._device_class = None + self._payload_on = None + self._payload_off = None + self._qos = None + self._force_update = None + self._off_delay = None + self._template = None + self._unique_id = None + + # Load config + self._setup_from_config(config) + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, self._qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config) + async def async_added_to_hass(self): """Subscribe mqtt events.""" await MqttAvailability.async_added_to_hass(self) await MqttDiscoveryUpdate.async_added_to_hass(self) + await self._subscribe_topics() + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._setup_from_config(config) + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + def _setup_from_config(self, config): + """(Re)Setup the entity.""" + self._name = config.get(CONF_NAME) + self._state_topic = config.get(CONF_STATE_TOPIC) + self._device_class = config.get(CONF_DEVICE_CLASS) + self._qos = config.get(CONF_QOS) + self._force_update = config.get(CONF_FORCE_UPDATE) + self._off_delay = config.get(CONF_OFF_DELAY) + self._payload_on = config.get(CONF_PAYLOAD_ON) + self._payload_off = config.get(CONF_PAYLOAD_OFF) + value_template = config.get(CONF_VALUE_TEMPLATE) + if value_template is not None and value_template.hass is None: + value_template.hass = self.hass + self._template = value_template + + self._unique_id = config.get(CONF_UNIQUE_ID) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" @callback def off_delay_listener(now): """Switch device off after a delay.""" @@ -163,8 +186,16 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, self.async_schedule_update_ha_state() - await mqtt.async_subscribe( - self.hass, self._state_topic, state_message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._state_topic, + 'msg_callback': state_message_received, + 'qos': self._qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + await MqttAvailability.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py new file mode 100644 index 00000000000..90a8b0b5813 --- /dev/null +++ b/homeassistant/components/binary_sensor/point.py @@ -0,0 +1,104 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.point/ +""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +EVENTS = { + 'battery': # On means low, Off means normal + ('battery_low', ''), + 'button_press': # On means the button was pressed, Off means normal + ('short_button_press', ''), + 'cold': # On means cold, Off means normal + ('temperature_low', 'temperature_risen_normal'), + 'connectivity': # On means connected, Off means disconnected + ('device_online', 'device_offline'), + 'dry': # On means too dry, Off means normal + ('humidity_low', 'humidity_risen_normal'), + 'heat': # On means hot, Off means normal + ('temperature_high', 'temperature_dropped_normal'), + 'moisture': # On means wet, Off means dry + ('humidity_high', 'humidity_dropped_normal'), + 'sound': # On means sound detected, Off means no sound (clear) + ('avg_sound_high', 'sound_level_dropped_normal'), + 'tamper': # On means the point was removed or attached + ('tamper', ''), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's binary sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointBinarySensor(client, device_id, device_class) + for device_class in EVENTS), True) + + +class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + + self._async_unsub_hook_dispatcher_connect = None + self._events = EVENTS[device_class] + self._is_on = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_WEBHOOK, self._webhook_event) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + await super().async_will_remove_from_hass() + if self._async_unsub_hook_dispatcher_connect: + self._async_unsub_hook_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if not self.is_updated: + return + if self._events[0] in self.device.ongoing_events: + self._is_on = True + else: + self._is_on = None + self.async_schedule_update_ha_state() + + @callback + def _webhook_event(self, data, webhook): + """Process new event from the webhook.""" + if self.device.webhook != webhook: + return + _type = data.get('event', {}).get('type') + if _type not in self._events: + return + _LOGGER.debug("Recieved webhook: %s", _type) + if _type == self._events[0]: + self._is_on = True + if _type == self._events[1]: + self._is_on = None + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return the state of the binary sensor.""" + if self.device_class == 'connectivity': + # connectivity is the other way around. + return not self._is_on + return self._is_on diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py index 12c9b3e98f0..4a671fc9512 100644 --- a/homeassistant/components/binary_sensor/rainmachine.py +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -8,28 +8,29 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.rainmachine import ( - BINARY_SENSORS, DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, TYPE_FREEZE, - TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, - TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity) -from homeassistant.const import CONF_MONITORED_CONDITIONS + BINARY_SENSORS, DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, + SENSOR_UPDATE_TOPIC, TYPE_FREEZE, TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, + TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, + RainMachineEntity) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['rainmachine'] - _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine binary sensors based on the old way.""" + pass - rainmachine = hass.data[DATA_RAINMACHINE] + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine binary sensors based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] binary_sensors = [] - for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + for sensor_type in rainmachine.binary_sensor_conditions: name, icon = BINARY_SENSORS[sensor_type] binary_sensors.append( RainMachineBinarySensor(rainmachine, sensor_type, name, icon)) @@ -70,15 +71,15 @@ class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): return '{0}_{1}'.format( self.rainmachine.device_mac.replace(':', ''), self._sensor_type) - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + @callback + def update(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update)) async def async_update(self): """Update the state.""" diff --git a/homeassistant/components/binary_sensor/sense.py b/homeassistant/components/binary_sensor/sense.py index 8c5ddda0383..1f83bffdcb6 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -60,7 +60,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[SENSE_DATA] sense_devices = data.get_discovered_device_data() - devices = [SenseDevice(data, device) for device in sense_devices] + devices = [SenseDevice(data, device) for device in sense_devices + if device['tags']['DeviceListAllowed'] == 'true'] add_entities(devices) diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py index 1f386fc2293..d5f8b16e0c1 100644 --- a/homeassistant/components/binary_sensor/template.py +++ b/homeassistant/components/binary_sensor/template.py @@ -58,10 +58,12 @@ async def async_setup_platform(hass, config, async_add_entities, entity_ids = set() manual_entity_ids = device_config.get(ATTR_ENTITY_ID) - for template in ( - value_template, - icon_template, - entity_picture_template, + invalid_templates = [] + + for tpl_name, template in ( + (CONF_VALUE_TEMPLATE, value_template), + (CONF_ICON_TEMPLATE, icon_template), + (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template), ): if template is None: continue @@ -73,6 +75,8 @@ async def async_setup_platform(hass, config, async_add_entities, template_entity_ids = template.extract_entities() if template_entity_ids == MATCH_ALL: entity_ids = MATCH_ALL + # Cut off _template from name + invalid_templates.append(tpl_name[:-9]) elif entity_ids != MATCH_ALL: entity_ids |= set(template_entity_ids) @@ -81,6 +85,14 @@ async def async_setup_platform(hass, config, async_add_entities, elif entity_ids != MATCH_ALL: entity_ids = list(entity_ids) + if invalid_templates: + _LOGGER.warning( + 'Template binary sensor %s has no entity ids configured to' + ' track nor were we able to extract the entities to track' + ' from the %s template(s). This entity will only be able' + ' to be updated manually.', + device, ', '.join(invalid_templates)) + friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device) device_class = device_config.get(CONF_DEVICE_CLASS) delay_on = device_config.get(CONF_DELAY_ON) @@ -132,10 +144,12 @@ class BinarySensorTemplate(BinarySensorDevice): @callback def template_bsensor_startup(event): """Update template on startup.""" - async_track_state_change( - self.hass, self._entities, template_bsensor_state_listener) + if self._entities != MATCH_ALL: + # Track state change only for valid templates + async_track_state_change( + self.hass, self._entities, template_bsensor_state_listener) - self.hass.async_add_job(self.async_check_state) + self.async_check_state() self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, template_bsensor_startup) @@ -233,3 +247,7 @@ class BinarySensorTemplate(BinarySensorDevice): async_track_same_state( self.hass, period, set_state, entity_ids=self._entities, async_check_same_func=lambda *args: self._async_render() == state) + + async def async_update(self): + """Force update of the state from the template.""" + self.async_check_state() diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 08838be3ea6..4773e88f5df 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -22,7 +22,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.15.3'] +REQUIREMENTS = ['numpy==1.15.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/w800rf32.py b/homeassistant/components/binary_sensor/w800rf32.py new file mode 100644 index 00000000000..48ac6f41a12 --- /dev/null +++ b/homeassistant/components/binary_sensor/w800rf32.py @@ -0,0 +1,132 @@ +""" +Support for w800rf32 binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.w800rf32/ + +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.components.w800rf32 import (W800RF32_DEVICE) +from homeassistant.const import (CONF_DEVICE_CLASS, CONF_NAME, CONF_DEVICES) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import event as evt +from homeassistant.util import dt as dt_util +from homeassistant.helpers.dispatcher import (async_dispatcher_connect) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['w800rf32'] +CONF_OFF_DELAY = 'off_delay' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICES): { + cv.string: vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OFF_DELAY): + vol.All(cv.time_period, cv.positive_timedelta) + }) + }, +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup_platform(hass, config, + add_entities, discovery_info=None): + """Set up the Binary Sensor platform to w800rf32.""" + binary_sensors = [] + # device_id --> "c1 or a3" X10 device. entity (type dictionary) + # --> name, device_class etc + for device_id, entity in config[CONF_DEVICES].items(): + + _LOGGER.debug("Add %s w800rf32.binary_sensor (class %s)", + entity[CONF_NAME], entity.get(CONF_DEVICE_CLASS)) + + device = W800rf32BinarySensor( + device_id, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS), + entity.get(CONF_OFF_DELAY)) + + binary_sensors.append(device) + + add_entities(binary_sensors) + + +class W800rf32BinarySensor(BinarySensorDevice): + """A representation of a w800rf32 binary sensor.""" + + def __init__(self, device_id, name, device_class=None, off_delay=None): + """Initialize the w800rf32 sensor.""" + self._signal = W800RF32_DEVICE.format(device_id) + self._name = name + self._device_class = device_class + self._off_delay = off_delay + self._state = False + self._delay_listener = None + + @callback + def _off_delay_listener(self, now): + """Switch device off after a delay.""" + self._delay_listener = None + self.update_state(False) + + @property + def name(self): + """Return the device name.""" + return self._name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the sensor class.""" + return self._device_class + + @property + def is_on(self): + """Return true if the sensor state is True.""" + return self._state + + @callback + def binary_sensor_update(self, event): + """Call for control updates from the w800rf32 gateway.""" + import W800rf32 as w800rf32mod + + if not isinstance(event, w800rf32mod.W800rf32Event): + return + + dev_id = event.device + command = event.command + + _LOGGER.debug( + "BinarySensor update (Device ID: %s Command %s ...)", + dev_id, command) + + # Update the w800rf32 device state + if command in ('On', 'Off'): + is_on = command == 'On' + self.update_state(is_on) + + if (self.is_on and self._off_delay is not None and + self._delay_listener is None): + + self._delay_listener = evt.async_track_point_in_time( + self.hass, self._off_delay_listener, + dt_util.utcnow() + self._off_delay) + + def update_state(self, state): + """Update the state of the device.""" + self._state = state + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + async_dispatcher_connect(self.hass, self._signal, + self.binary_sensor_update) diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 45217c42c1d..550bdaac172 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -209,7 +209,7 @@ class XiaomiMotionSensor(XiaomiBinarySensor): else: self._should_poll = True if self.entity_id is not None: - self._hass.bus.fire('motion', { + self._hass.bus.fire('xiaomi_aqara.motion', { 'entity_id': self.entity_id }) @@ -417,7 +417,7 @@ class XiaomiButton(XiaomiBinarySensor): _LOGGER.warning("Unsupported click_type detected: %s", value) return False - self._hass.bus.fire('click', { + self._hass.bus.fire('xiaomi_aqara.click', { 'entity_id': self.entity_id, 'click_type': click_type }) @@ -453,14 +453,14 @@ class XiaomiCube(XiaomiBinarySensor): def parse_data(self, data, raw_data): """Parse data sent by gateway.""" if self._data_key in data: - self._hass.bus.fire('cube_action', { + self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': data[self._data_key] }) self._last_action = data[self._data_key] if 'rotate' in data: - self._hass.bus.fire('cube_action', { + self._hass.bus.fire('xiaomi_aqara.cube_action', { 'entity_id': self.entity_id, 'action_type': 'rotate', 'action_value': float(data['rotate'].replace(",", ".")) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 66cfe3990a3..62e73a52cc8 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.1'] +REQUIREMENTS = ['blinkpy==0.10.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index 42ad7d6fa66..7bda891e921 100644 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -89,13 +89,12 @@ class MqttCamera(Camera): """Return a unique ID.""" return self._unique_id - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe MQTT events.""" @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" self._last_image = payload - return mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic, message_received, self._qos, None) diff --git a/homeassistant/components/cast/.translations/es.json b/homeassistant/components/cast/.translations/es.json index 9188055849c..6dc41196af5 100644 --- a/homeassistant/components/cast/.translations/es.json +++ b/homeassistant/components/cast/.translations/es.json @@ -1,5 +1,15 @@ { "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos de Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfQuieres configurar Google Cast?", + "title": "Google Cast" + } + }, "title": "Google Cast" } } \ No newline at end of file diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index a165521f0bd..4b73e24fb41 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -249,9 +249,11 @@ class ClimateDevice(Entity): self.hass, self.target_temperature_low, self.temperature_unit, self.precision) + if self.current_humidity is not None: + data[ATTR_CURRENT_HUMIDITY] = self.current_humidity + if supported_features & SUPPORT_TARGET_HUMIDITY: data[ATTR_HUMIDITY] = self.target_humidity - data[ATTR_CURRENT_HUMIDITY] = self.current_humidity if supported_features & SUPPORT_TARGET_HUMIDITY_LOW: data[ATTR_MIN_HUMIDITY] = self.min_humidity diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 63b8f585c7e..4a5c3258893 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pydaikin==0.6'] +REQUIREMENTS = ['pydaikin==0.8'] _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,6 @@ class DaikinClimate(ClimateDevice): from pydaikin import appliance self._api = api - self._force_refresh = False self._list = { ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN), ATTR_FAN_MODE: list( @@ -102,19 +101,11 @@ class DaikinClimate(ClimateDevice): self._supported_features = SUPPORT_TARGET_TEMPERATURE \ | SUPPORT_OPERATION_MODE - daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_FAN_MODE] - if self._api.device.values.get(daikin_attr) is not None: + if self._api.device.support_fan_mode: self._supported_features |= SUPPORT_FAN_MODE - else: - # even devices without support must have a default valid value - self._api.device.values[daikin_attr] = 'A' - daikin_attr = HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE] - if self._api.device.values.get(daikin_attr) is not None: + if self._api.device.support_swing_mode: self._supported_features |= SUPPORT_SWING_MODE - else: - # even devices without support must have a default valid value - self._api.device.values[daikin_attr] = '0' def get(self, key): """Retrieve device settings from API library cache.""" @@ -189,7 +180,6 @@ class DaikinClimate(ClimateDevice): _LOGGER.error("Invalid temperature %s", value) if values: - self._force_refresh = True self._api.device.set(values) @property @@ -270,5 +260,4 @@ class DaikinClimate(ClimateDevice): def update(self): """Retrieve latest state.""" - self._api.update(no_throttle=self._force_refresh) - self._force_refresh = False + self._api.update() diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index d421157c2ec..212c4265d8a 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -17,7 +17,8 @@ from homeassistant.components.climate import ( SUPPORT_AWAY_MODE, SUPPORT_TARGET_TEMPERATURE, PLATFORM_SCHEMA) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, ATTR_ENTITY_ID, - SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN) + SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_UNKNOWN, PRECISION_HALVES, + PRECISION_TENTHS, PRECISION_WHOLE) from homeassistant.helpers import condition from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) @@ -43,6 +44,7 @@ CONF_HOT_TOLERANCE = 'hot_tolerance' CONF_KEEP_ALIVE = 'keep_alive' CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode' CONF_AWAY_TEMP = 'away_temp' +CONF_PRECISION = 'precision' SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) @@ -63,7 +65,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ cv.time_period, cv.positive_timedelta), vol.Optional(CONF_INITIAL_OPERATION_MODE): vol.In([STATE_AUTO, STATE_OFF]), - vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float) + vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float), + vol.Optional(CONF_PRECISION): vol.In( + [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]), }) @@ -83,11 +87,13 @@ async def async_setup_platform(hass, config, async_add_entities, keep_alive = config.get(CONF_KEEP_ALIVE) initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE) away_temp = config.get(CONF_AWAY_TEMP) + precision = config.get(CONF_PRECISION) async_add_entities([GenericThermostat( hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, - hot_tolerance, keep_alive, initial_operation_mode, away_temp)]) + hot_tolerance, keep_alive, initial_operation_mode, away_temp, + precision)]) class GenericThermostat(ClimateDevice): @@ -96,7 +102,7 @@ class GenericThermostat(ClimateDevice): def __init__(self, hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp, target_temp, ac_mode, min_cycle_duration, cold_tolerance, hot_tolerance, keep_alive, - initial_operation_mode, away_temp): + initial_operation_mode, away_temp, precision): """Initialize the thermostat.""" self.hass = hass self._name = name @@ -109,6 +115,7 @@ class GenericThermostat(ClimateDevice): self._initial_operation_mode = initial_operation_mode self._saved_target_temp = target_temp if target_temp is not None \ else away_temp + self._temp_precision = precision if self.ac_mode: self._current_operation = STATE_COOL self._operation_list = [STATE_COOL, STATE_OFF] @@ -202,6 +209,13 @@ class GenericThermostat(ClimateDevice): """Return the name of the thermostat.""" return self._name + @property + def precision(self): + """Return the precision of the system.""" + if self._temp_precision is not None: + return self._temp_precision + return super().precision + @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index 5b741a87b45..5233501ec30 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -7,17 +7,16 @@ https://home-assistant.io/components/climate.homematic/ import logging from homeassistant.components.climate import ( - STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - ClimateDevice) + STATE_AUTO, STATE_MANUAL, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, ClimateDevice) from homeassistant.components.homematic import ( ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice) -from homeassistant.const import ATTR_TEMPERATURE, STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS DEPENDENCIES = ['homematic'] _LOGGER = logging.getLogger(__name__) -STATE_MANUAL = 'manual' STATE_BOOST = 'boost' STATE_COMFORT = 'comfort' STATE_LOWERING = 'lowering' @@ -41,7 +40,7 @@ HM_HUMI_MAP = [ ] HM_CONTROL_MODE = 'CONTROL_MODE' -HM_IP_CONTROL_MODE = 'SET_POINT_MODE' +HMIP_CONTROL_MODE = 'SET_POINT_MODE' SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE @@ -78,21 +77,17 @@ class HMThermostat(HMDevice, ClimateDevice): if HM_CONTROL_MODE not in self._data: return None - set_point_mode = self._data.get('SET_POINT_MODE', -1) - control_mode = self._data.get('CONTROL_MODE', -1) - boost_mode = self._data.get('BOOST_MODE', False) - # boost mode is active - if boost_mode: + if self._data.get('BOOST_MODE', False): return STATE_BOOST - # HM ip etrv 2 uses the set_point_mode to say if its + # HmIP uses the set_point_mode to say if its # auto or manual - if not set_point_mode == -1: - code = set_point_mode + if HMIP_CONTROL_MODE in self._data: + code = self._data[HMIP_CONTROL_MODE] # Other devices use the control_mode else: - code = control_mode + code = self._data['CONTROL_MODE'] # get the name of the mode name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code] @@ -101,12 +96,15 @@ class HMThermostat(HMDevice, ClimateDevice): @property def operation_list(self): """Return the list of available operation modes.""" - op_list = [] + # HMIP use set_point_mode for operation + if HMIP_CONTROL_MODE in self._data: + return [STATE_MANUAL, STATE_AUTO, STATE_BOOST] + # HM + op_list = [] for mode in self._hmdevice.ACTIONNODE: if mode in HM_STATE_MAP: op_list.append(HM_STATE_MAP.get(mode)) - return op_list @property @@ -157,11 +155,11 @@ class HMThermostat(HMDevice, ClimateDevice): def _init_data_struct(self): """Generate a data dict (self._data) from the Homematic metadata.""" self._state = next(iter(self._hmdevice.WRITENODE.keys())) - self._data[self._state] = STATE_UNKNOWN + self._data[self._state] = None if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \ - HM_IP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: - self._data[HM_CONTROL_MODE] = STATE_UNKNOWN + HMIP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE: + self._data[HM_CONTROL_MODE] = None for node in self._hmdevice.SENSORNODE.keys(): - self._data[node] = STATE_UNKNOWN + self._data[node] = None diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index bfb18fa0a4c..25beedfe0dd 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -88,6 +88,12 @@ class MelissaClimate(ClimateDevice): if self._data: return self._data[self._api.TEMP] + @property + def current_humidity(self): + """Return the current humidity value.""" + if self._data: + return self._data[self._api.HUMIDITY] + @property def target_temperature_step(self): """Return the supported step of target temperature.""" @@ -113,8 +119,9 @@ class MelissaClimate(ClimateDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._cur_settings is not None: - return self._cur_settings[self._api.TEMP] + if self._cur_settings is None: + return None + return self._cur_settings[self._api.TEMP] @property def state(self): diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index a533cc37fd3..6be4fe183b7 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.2'] +REQUIREMENTS = ['millheater==0.2.8'] _LOGGER = logging.getLogger(__name__) @@ -32,8 +32,7 @@ MIN_TEMP = 5 SERVICE_SET_ROOM_TEMP = 'mill_set_room_temperature' SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | - SUPPORT_FAN_MODE | SUPPORT_ON_OFF | - SUPPORT_OPERATION_MODE) + SUPPORT_FAN_MODE) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, @@ -92,12 +91,14 @@ class MillHeater(ClimateDevice): @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + if self._heater.is_gen1: + return SUPPORT_FLAGS + return SUPPORT_FLAGS | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE @property def available(self): """Return True if entity is available.""" - return self._heater.device_status == 0 # weird api choice + return self._heater.available @property def unique_id(self): @@ -112,16 +113,18 @@ class MillHeater(ClimateDevice): @property def device_state_attributes(self): """Return the state attributes.""" - if self._heater.room: - room = self._heater.room.name - else: - room = "Independent device" - return { - "room": room, + res = { "open_window": self._heater.open_window, "heating": self._heater.is_heating, "controlled_by_tibber": self._heater.tibber_control, + "heater_generation": 1 if self._heater.is_gen1 else 2, } + if self._heater.room: + res['room'] = self._heater.room.name + res['avg_room_temp'] = self._heater.room.avg_temp + else: + res['room'] = "Independent device" + return res @property def temperature_unit(self): @@ -156,6 +159,8 @@ class MillHeater(ClimateDevice): @property def is_on(self): """Return true if heater is on.""" + if self._heater.is_gen1: + return True return self._heater.power_status == 1 @property @@ -176,6 +181,8 @@ class MillHeater(ClimateDevice): @property def operation_list(self): """List of available operation modes.""" + if self._heater.is_gen1: + return None return [STATE_HEAT, STATE_OFF] async def async_set_temperature(self, **kwargs): @@ -210,7 +217,7 @@ class MillHeater(ClimateDevice): """Set operation mode.""" if operation_mode == STATE_HEAT: await self.async_turn_on() - elif operation_mode == STATE_OFF: + elif operation_mode == STATE_OFF and not self._heater.is_gen1: await self.async_turn_off() else: _LOGGER.error("Unrecognized operation mode: %s", operation_mode) diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index bc63512fcf3..e580476e56a 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -168,18 +168,14 @@ class NestThermostat(ClimateDevice): @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._mode != NEST_MODE_HEAT_COOL and \ - self._mode != STATE_ECO and \ - not self.is_away_mode_on: + if self._mode not in (NEST_MODE_HEAT_COOL, STATE_ECO): return self._target_temperature return None @property def target_temperature_low(self): """Return the lower bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[0]: - # eco_temperature is always a low, high tuple + if self._mode == STATE_ECO: return self._eco_temperature[0] if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[0] @@ -188,9 +184,7 @@ class NestThermostat(ClimateDevice): @property def target_temperature_high(self): """Return the upper bound temperature we try to reach.""" - if (self.is_away_mode_on or self._mode == STATE_ECO) and \ - self._eco_temperature[1]: - # eco_temperature is always a low, high tuple + if self._mode == STATE_ECO: return self._eco_temperature[1] if self._mode == NEST_MODE_HEAT_COOL: return self._target_temperature[1] diff --git a/homeassistant/components/climate/velbus.py b/homeassistant/components/climate/velbus.py new file mode 100644 index 00000000000..0b0205acefb --- /dev/null +++ b/homeassistant/components/climate/velbus.py @@ -0,0 +1,74 @@ +""" +Support for Velbus thermostat. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/climate.velbus/ +""" +import logging + +from homeassistant.components.climate import ( + STATE_HEAT, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['velbus'] + +SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the Velbus thermostat platform.""" + if discovery_info is None: + return + + sensors = [] + for sensor in discovery_info: + module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) + channel = sensor[1] + sensors.append(VelbusClimate(module, channel)) + + async_add_entities(sensors) + + +class VelbusClimate(VelbusEntity, ClimateDevice): + """Representation of a Velbus thermostat.""" + + @property + def supported_features(self): + """Return the list off supported features.""" + return SUPPORT_FLAGS + + @property + def temperature_unit(self): + """Return the unit this state is expressed in.""" + if self._module.get_unit(self._channel) == '°C': + return TEMP_CELSIUS + return TEMP_FAHRENHEIT + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._module.get_state(self._channel) + + @property + def current_operation(self): + """Return current operation.""" + return STATE_HEAT + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._module.get_climate_target() + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: + return + self._module.set_temp(temp) + self.schedule_update_ha_state() diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index bc486eb7ead..4f4b0c582fc 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -12,24 +12,20 @@ import os import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME) + 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.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 . import http_api, iot, auth_api +from . import http_api, iot, auth_api, prefs from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 -STORAGE_ENABLE_ALEXA = 'alexa_enabled' -STORAGE_ENABLE_GOOGLE = 'google_enabled' _LOGGER = logging.getLogger(__name__) -_UNDEF = object() CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' @@ -68,7 +64,7 @@ ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ }) GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA} + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, }) CONFIG_SCHEMA = vol.Schema({ @@ -124,12 +120,11 @@ class Cloud: self.alexa_config = alexa self.google_actions_user_conf = google_actions self._gactions_config = None - self._prefs = None + self.prefs = prefs.CloudPreferences(hass) self.id_token = None self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -184,26 +179,20 @@ class Cloud: 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, agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), + allow_unlock=self.prefs.google_allow_unlock, ) return self._gactions_config - @property - def alexa_enabled(self): - """Return if Alexa is enabled.""" - return self._prefs[STORAGE_ENABLE_ALEXA] - - @property - def google_enabled(self): - """Return if Google is enabled.""" - return self._prefs[STORAGE_ENABLE_GOOGLE] - def path(self, *parts): """Get config path inside cloud dir. @@ -243,20 +232,6 @@ class Cloud: async def async_start(self, _): """Start the cloud component.""" - prefs = await self._store.async_load() - if prefs is None: - prefs = {} - if self.mode not in prefs: - # Default to True if already logged in to make this not a - # breaking change. - enabled = await self.hass.async_add_executor_job( - os.path.isfile, self.user_info_path) - prefs = { - STORAGE_ENABLE_ALEXA: enabled, - STORAGE_ENABLE_GOOGLE: enabled, - } - self._prefs = prefs - def load_config(): """Load config.""" # Ensure config dir exists @@ -273,6 +248,8 @@ class Cloud: info = await self.hass.async_add_job(load_config) + await self.prefs.async_initialize(bool(info)) + if info is None: return @@ -282,15 +259,6 @@ class Cloud: self.hass.add_job(self.iot.connect()) - async def update_preferences(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF): - """Update user preferences.""" - if google_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_GOOGLE] = google_enabled - if alexa_enabled is not _UNDEF: - self._prefs[STORAGE_ENABLE_ALEXA] = alexa_enabled - await self._store.async_save(self._prefs) - def _decode_claims(self, token): # pylint: disable=no-self-use """Decode the claims in a token.""" from jose import jwt diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 88fb88474a1..abc72da796c 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -3,6 +3,10 @@ DOMAIN = 'cloud' CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 +PREF_ENABLE_ALEXA = 'alexa_enabled' +PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' + SERVERS = { 'production': { 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index cb62d773dfd..7b509f4eae2 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -15,7 +15,9 @@ 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 +from .const import ( + DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -30,8 +32,9 @@ SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ WS_TYPE_UPDATE_PREFS = 'cloud/update_prefs' SCHEMA_WS_UPDATE_PREFS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_PREFS, - vol.Optional('google_enabled'): bool, - vol.Optional('alexa_enabled'): bool, + vol.Optional(PREF_ENABLE_GOOGLE): bool, + vol.Optional(PREF_ENABLE_ALEXA): bool, + vol.Optional(PREF_GOOGLE_ALLOW_UNLOCK): bool, }) @@ -288,7 +291,7 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') - await cloud.update_preferences(**changes) + await cloud.prefs.async_update(**changes) connection.send_message(websocket_api.result_message( msg['id'], {'success': True})) @@ -308,10 +311,9 @@ def _account_data(cloud): 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, - 'google_enabled': cloud.google_enabled, + 'prefs': cloud.prefs.as_dict(), 'google_entities': cloud.google_actions_user_conf['filter'].config, 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), - 'alexa_enabled': cloud.alexa_enabled, 'alexa_entities': cloud.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index b4f228a630d..c5657ae9729 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -229,7 +229,7 @@ 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.alexa_enabled) + enabled=cloud.prefs.alexa_enabled) return result @@ -237,7 +237,7 @@ def async_handle_alexa(hass, cloud, payload): @asyncio.coroutine def async_handle_google_actions(hass, cloud, payload): """Handle an incoming IoT message for Google Actions.""" - if not cloud.google_enabled: + if not cloud.prefs.google_enabled: return ga.turned_off_response(payload) result = yield from ga.async_handle_message( diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py new file mode 100644 index 00000000000..7e1ec6a0232 --- /dev/null +++ b/homeassistant/components/cloud/prefs.py @@ -0,0 +1,64 @@ +"""Preference management for cloud.""" +from .const import ( + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + PREF_GOOGLE_ALLOW_UNLOCK) + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +_UNDEF = object() + + +class CloudPreferences: + """Handle cloud preferences.""" + + def __init__(self, hass): + """Initialize cloud prefs.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._prefs = None + + async def async_initialize(self, logged_in): + """Finish initializing the preferences.""" + prefs = await self._store.async_load() + + if prefs is None: + # Backwards compat: we enable alexa/google if already logged in + prefs = { + PREF_ENABLE_ALEXA: logged_in, + PREF_ENABLE_GOOGLE: logged_in, + PREF_GOOGLE_ALLOW_UNLOCK: False, + } + await self._store.async_save(prefs) + + self._prefs = prefs + + async def async_update(self, *, google_enabled=_UNDEF, + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + """Update user preferences.""" + for key, value in ( + (PREF_ENABLE_GOOGLE, google_enabled), + (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), + ): + if value is not _UNDEF: + self._prefs[key] = value + + await self._store.async_save(self._prefs) + + def as_dict(self): + """Return dictionary version.""" + return self._prefs + + @property + def alexa_enabled(self): + """Return if Alexa is enabled.""" + return self._prefs[PREF_ENABLE_ALEXA] + + @property + def google_enabled(self): + """Return if Google is enabled.""" + return self._prefs[PREF_ENABLE_GOOGLE] + + @property + def google_allow_unlock(self): + """Return if Google is allowed to unlock locks.""" + return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index 154320b4abd..98c321b9f5a 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -21,6 +21,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = 'coinbase' CONF_API_SECRET = 'api_secret' +CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies' CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies' MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) @@ -31,6 +32,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_API_SECRET): cv.string, + vol.Optional(CONF_ACCOUNT_CURRENCIES): + vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]): vol.All(cv.ensure_list, [cv.string]) }) @@ -45,6 +48,7 @@ def setup(hass, config): """ api_key = config[DOMAIN].get(CONF_API_KEY) api_secret = config[DOMAIN].get(CONF_API_SECRET) + account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES) exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES) hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData( @@ -53,7 +57,13 @@ def setup(hass, config): if not hasattr(coinbase_data, 'accounts'): return False for account in coinbase_data.accounts.data: - load_platform(hass, 'sensor', DOMAIN, {'account': account}, config) + if (account_currencies is None or + account.currency in account_currencies): + load_platform(hass, + 'sensor', + DOMAIN, + {'account': account}, + config) for currency in exchange_currencies: if currency not in coinbase_data.exchange_rates.rates: _LOGGER.warning("Currency %s not found", currency) diff --git a/homeassistant/components/cover/deconz.py b/homeassistant/components/cover/deconz.py index cd5871e153a..be60997869c 100644 --- a/homeassistant/components/cover/deconz.py +++ b/homeassistant/components/cover/deconz.py @@ -5,7 +5,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.deconz/ """ from homeassistant.components.deconz.const import ( - COVER_TYPES, DAMPERS, DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, WINDOW_COVERS) + COVER_TYPES, DAMPERS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, + WINDOW_COVERS) from homeassistant.components.cover import ( ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, SUPPORT_SET_POSITION) @@ -29,6 +30,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): Covers are based on same device class as lights in deCONZ. """ + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_cover(lights): """Add cover from deCONZ.""" @@ -36,23 +39,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for light in lights: if light.type in COVER_TYPES: if light.modelid in ZIGBEE_SPEC: - entities.append(DeconzCoverZigbeeSpec(light)) + entities.append(DeconzCoverZigbeeSpec(light, gateway)) else: - entities.append(DeconzCover(light)) + entities.append(DeconzCover(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_cover)) - async_add_cover(hass.data[DATA_DECONZ].api.lights.values()) + async_add_cover(gateway.api.lights.values()) class DeconzCover(CoverDevice): """Representation of a deCONZ cover.""" - def __init__(self, cover): + def __init__(self, cover, gateway): """Set up cover and add update callback to get data from websocket.""" self._cover = cover + self.gateway = gateway + self.unsub_dispatcher = None + self._features = SUPPORT_OPEN self._features |= SUPPORT_CLOSE self._features |= SUPPORT_STOP @@ -61,11 +67,14 @@ class DeconzCover(CoverDevice): async def async_added_to_hass(self): """Subscribe to covers events.""" self._cover.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._cover.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._cover.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect cover object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._cover.remove_callback(self.async_update_callback) self._cover = None @@ -112,7 +121,7 @@ class DeconzCover(CoverDevice): @property def available(self): """Return True if light is available.""" - return self._cover.reachable + return self.gateway.available and self._cover.reachable @property def should_poll(self): @@ -150,7 +159,7 @@ class DeconzCover(CoverDevice): self._cover.uniqueid.count(':') != 7): return None serial = self._cover.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/cover/fibaro.py b/homeassistant/components/cover/fibaro.py new file mode 100644 index 00000000000..dc82087f802 --- /dev/null +++ b/homeassistant/components/cover/fibaro.py @@ -0,0 +1,92 @@ +""" +Support for Fibaro cover - curtains, rollershutters etc. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.fibaro/ +""" +import logging + +from homeassistant.components.cover import ( + CoverDevice, ENTITY_ID_FORMAT, ATTR_POSITION, ATTR_TILT_POSITION) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro covers.""" + if discovery_info is None: + return + + add_entities( + [FibaroCover(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['cover']], True) + + +class FibaroCover(FibaroDevice, CoverDevice): + """Representation a Fibaro Cover.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Vera device.""" + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @staticmethod + def bound(position): + """Normalize the position.""" + if position is None: + return None + position = int(position) + if position <= 5: + return 0 + if position >= 95: + return 100 + return position + + @property + def current_cover_position(self): + """Return current position of cover. 0 is closed, 100 is open.""" + return self.bound(self.level) + + @property + def current_cover_tilt_position(self): + """Return the current tilt position for venetian blinds.""" + return self.bound(self.level2) + + def set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level(kwargs.get(ATTR_POSITION)) + + def set_cover_tilt_position(self, **kwargs): + """Move the cover to a specific position.""" + self.set_level2(kwargs.get(ATTR_TILT_POSITION)) + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self.current_cover_position is None: + return None + return self.current_cover_position == 0 + + def open_cover(self, **kwargs): + """Open the cover.""" + self.action("open") + + def close_cover(self, **kwargs): + """Close the cover.""" + self.action("close") + + def open_cover_tilt(self, **kwargs): + """Open the cover tilt.""" + self.set_level2(100) + + def close_cover_tilt(self, **kwargs): + """Close the cover.""" + self.set_level2(0) + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.action("stop") diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 235b28b5be2..f51cca8a276 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -279,21 +279,19 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) + if payload.isnumeric(): - if 0 <= int(payload) <= 100: - percentage_payload = int(payload) - else: - percentage_payload = self.find_percentage_in_range( - float(payload), COVER_PAYLOAD) - if 0 <= percentage_payload <= 100: - self._position = percentage_payload - self._state = self._position == self._position_closed + percentage_payload = self.find_percentage_in_range( + float(payload), COVER_PAYLOAD) + self._position = percentage_payload + self._state = percentage_payload == DEFAULT_POSITION_CLOSED else: _LOGGER.warning( "Payload is not integer within range: %s", payload) return self.async_schedule_update_ha_state() + if self._get_position_topic: await mqtt.async_subscribe( self.hass, self._get_position_topic, @@ -374,7 +372,8 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Optimistically assume that cover has changed state. self._state = False if self._get_position_topic: - self._position = self._position_open + self._position = self.find_percentage_in_range( + self._position_open, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_close_cover(self, **kwargs): @@ -389,7 +388,8 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # Optimistically assume that cover has changed state. self._state = True if self._get_position_topic: - self._position = self._position_closed + self._position = self.find_percentage_in_range( + self._position_closed, COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_stop_cover(self, **kwargs): @@ -469,6 +469,11 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, offset_position = position - min_range position_percentage = round( float(offset_position) / current_range * 100.0) + + max_percent = 100 + min_percent = 0 + position_percentage = min(max(position_percentage, min_percent), + max_percent) if range_type == TILT_PAYLOAD and self._tilt_invert: return 100 - position_percentage return position_percentage diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index 5ceb4260d0c..bdff232fec9 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -9,18 +9,15 @@ import logging import voluptuous as vol from homeassistant.components.cover import ( - CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN) + PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN, CoverDevice) from homeassistant.const import ( CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING) -import homeassistant.helpers.config_validation as cv - -REQUIREMENTS = ['pymyq==0.0.15'] +from homeassistant.helpers import aiohttp_client, config_validation as cv +REQUIREMENTS = ['pymyq==1.0.0'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'myq' - MYQ_TO_HASS = { 'closed': STATE_CLOSED, 'closing': STATE_CLOSING, @@ -28,95 +25,69 @@ MYQ_TO_HASS = { 'opening': STATE_OPENING } -NOTIFICATION_ID = 'myq_notification' -NOTIFICATION_TITLE = 'MyQ Cover Setup' - -COVER_SCHEMA = vol.Schema({ +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_TYPE): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string }) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the MyQ component.""" - from pymyq import MyQAPI as pymyq +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the platform.""" + from pymyq import login + from pymyq.errors import MyQError, UnsupportedBrandError - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - brand = config.get(CONF_TYPE) - myq = pymyq(username, password, brand) + websession = aiohttp_client.async_get_clientsession(hass) + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + brand = config[CONF_TYPE] try: - if not myq.is_supported_brand(): - raise ValueError("Unsupported type. See documentation") + myq = await login(username, password, brand, websession) + except UnsupportedBrandError: + _LOGGER.error('Unsupported brand: %s', brand) + return + except MyQError as err: + _LOGGER.error('There was an error while logging in: %s', err) + return - if not myq.is_login_valid(): - raise ValueError("Username or Password is incorrect") - - add_entities(MyQDevice(myq, door) for door in myq.get_garage_doors()) - return True - - except (TypeError, KeyError, NameError, ValueError) as ex: - _LOGGER.error("%s", ex) - hass.components.persistent_notification.create( - 'Error: {}
' - 'You will need to restart hass after fixing.' - ''.format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + devices = await myq.get_devices() + async_add_entities([MyQDevice(device) for device in devices], True) class MyQDevice(CoverDevice): """Representation of a MyQ cover.""" - def __init__(self, myq, device): + def __init__(self, device): """Initialize with API object, device id.""" - self.myq = myq - self.device_id = device['deviceid'] - self._name = device['name'] - self._status = None + self._device = device @property def device_class(self): """Define this cover as a garage door.""" return 'garage' - @property - def should_poll(self): - """Poll for state.""" - return True - @property def name(self): """Return the name of the garage door if any.""" - return self._name if self._name else DEFAULT_NAME + return self._device.name @property def is_closed(self): """Return true if cover is closed, else False.""" - if self._status in [None, False]: - return None - return MYQ_TO_HASS.get(self._status) == STATE_CLOSED + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED @property def is_closing(self): """Return if the cover is closing or not.""" - return MYQ_TO_HASS.get(self._status) == STATE_CLOSING + return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return MYQ_TO_HASS.get(self._status) == STATE_OPENING - - def close_cover(self, **kwargs): - """Issue close command to cover.""" - self.myq.close_device(self.device_id) - - def open_cover(self, **kwargs): - """Issue open command to cover.""" - self.myq.open_device(self.device_id) + return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING @property def supported_features(self): @@ -126,8 +97,16 @@ class MyQDevice(CoverDevice): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return self.device_id + return self._device.device_id - def update(self): + async def async_close_cover(self, **kwargs): + """Issue close command to cover.""" + await self._device.close() + + async def async_open_cover(self, **kwargs): + """Issue open command to cover.""" + await self._device.open() + + async def async_update(self): """Update status of cover.""" - self._status = self.myq.get_status(self.device_id) + await self._device.update() diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 20da244a698..4fcd33bee26 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -19,7 +19,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['pydaikin==0.6'] +REQUIREMENTS = ['pydaikin==0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index f55f64ca430..0c60953db56 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Host", - "port": "Port (default value: '80')" + "port": "Port" }, "title": "Define deCONZ gateway" }, diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json new file mode 100644 index 00000000000..34661f447d8 --- /dev/null +++ b/homeassistant/components/deconz/.translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado", + "no_bridges": "No se han descubierto puentes deCONZ", + "one_instance_only": "El componente s\u00f3lo soporta una instancia deCONZ" + }, + "error": { + "no_key": "No se pudo obtener una clave API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Puerto" + }, + "title": "Definir pasarela deCONZ" + }, + "link": { + "description": "Desbloquee su pasarela deCONZ para registrarse con Home Assistant. \n\n 1. Ir a la configuraci\u00f3n del sistema deCONZ \n 2. Presione el bot\u00f3n \"Desbloquear Gateway\"", + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir importar sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + }, + "title": "Opciones de configuraci\u00f3n adicionales para deCONZ" + } + }, + "title": "Pasarela Zigbee deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 27868814eab..1b0407e633d 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Vert", - "port": "Port (standardverdi: '80')" + "port": "Port" }, "title": "Definer deCONZ-gatewayen" }, diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index c314a1191db..4d3e2cbc6a9 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -11,11 +11,10 @@ from homeassistant.const import ( CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.util.json import load_json # Loading the config flow file will register the flow from .config_flow import configured_hosts -from .const import CONFIG_FILE, DOMAIN, _LOGGER +from .const import DEFAULT_PORT, DOMAIN, _LOGGER from .gateway import DeconzGateway REQUIREMENTS = ['pydeconz==47'] @@ -27,7 +26,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_API_KEY): cv.string, vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=80): cv.port, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, }) }, extra=vol.ALLOW_EXTRA) @@ -53,11 +52,7 @@ async def async_setup(hass, config): """ if DOMAIN in config: deconz_config = None - config_file = await hass.async_add_job( - load_json, hass.config.path(CONFIG_FILE)) - if config_file: - deconz_config = config_file - elif CONF_HOST in config[DOMAIN]: + if CONF_HOST in config[DOMAIN]: deconz_config = config[DOMAIN] if deconz_config and not configured_hosts(hass): hass.async_add_job(hass.config_entries.flow.async_init( diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 293b6c1b540..f7bc71a2398 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -6,11 +6,9 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.helpers import aiohttp_client -from homeassistant.util.json import load_json from .const import ( - CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DOMAIN) - + CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, DEFAULT_PORT, DOMAIN) CONF_BRIDGEID = 'bridgeid' @@ -35,6 +33,10 @@ class DeconzFlowHandler(config_entries.ConfigFlow): self.deconz_config = {} async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): """Handle a deCONZ config flow start. Only allows one instance to be set up. @@ -51,6 +53,8 @@ class DeconzFlowHandler(config_entries.ConfigFlow): if bridge[CONF_HOST] == user_input[CONF_HOST]: self.deconz_config = bridge return await self.async_step_link() + self.deconz_config = user_input + return await self.async_step_link() session = aiohttp_client.async_get_clientsession(self.hass) self.bridges = await async_discovery(session) @@ -58,19 +62,24 @@ class DeconzFlowHandler(config_entries.ConfigFlow): if len(self.bridges) == 1: self.deconz_config = self.bridges[0] return await self.async_step_link() + if len(self.bridges) > 1: hosts = [] for bridge in self.bridges: hosts.append(bridge[CONF_HOST]) return self.async_show_form( - step_id='user', + step_id='init', data_schema=vol.Schema({ vol.Required(CONF_HOST): vol.In(hosts) }) ) - return self.async_abort( - reason='no_bridges' + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + }), ) async def async_step_link(self, user_input=None): @@ -135,13 +144,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow): deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') - config_file = await self.hass.async_add_job( - load_json, self.hass.config.path(CONFIG_FILE)) - if config_file and \ - config_file[CONF_HOST] == deconz_config[CONF_HOST] and \ - CONF_API_KEY in config_file: - deconz_config[CONF_API_KEY] = config_file[CONF_API_KEY] - return await self.async_step_import(deconz_config) async def async_step_import(self, import_config): diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index ccd1eac77ea..b08f3d71824 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -4,11 +4,8 @@ import logging _LOGGER = logging.getLogger('homeassistant.components.deconz') DOMAIN = 'deconz' -CONFIG_FILE = 'deconz.conf' -DATA_DECONZ_EVENT = 'deconz_events' -DATA_DECONZ_ID = 'deconz_entities' -DATA_DECONZ_UNSUB = 'deconz_dispatchers' -DECONZ_DOMAIN = 'deconz' + +DEFAULT_PORT = 80 CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' @@ -16,6 +13,8 @@ CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' SUPPORTED_PLATFORMS = ['binary_sensor', 'cover', 'light', 'scene', 'sensor', 'switch'] +DECONZ_REACHABLE = 'deconz_reachable' + ATTR_DARK = 'dark' ATTR_ON = 'on' diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index a64f9af886b..8d33e011b94 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -8,7 +8,7 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.util import slugify from .const import ( - _LOGGER, CONF_ALLOW_CLIP_SENSOR, SUPPORTED_PLATFORMS) + _LOGGER, DECONZ_REACHABLE, CONF_ALLOW_CLIP_SENSOR, SUPPORTED_PLATFORMS) class DeconzGateway: @@ -18,6 +18,7 @@ class DeconzGateway: """Initialize the system.""" self.hass = hass self.config_entry = config_entry + self.available = True self.api = None self._cancel_retry_setup = None @@ -30,7 +31,8 @@ class DeconzGateway: hass = self.hass self.api = await get_gateway( - hass, self.config_entry.data, self.async_add_device_callback + hass, self.config_entry.data, self.async_add_device_callback, + self.async_connection_status_callback ) if self.api is False: @@ -65,6 +67,13 @@ class DeconzGateway: return True + @callback + def async_connection_status_callback(self, available): + """Handle signals of gateway connection status.""" + self.available = available + async_dispatcher_send( + self.hass, DECONZ_REACHABLE, {'state': True, 'attr': 'reachable'}) + @callback def async_add_device_callback(self, device_type, device): """Handle event of new device creation in deCONZ.""" @@ -122,13 +131,15 @@ class DeconzGateway: return True -async def get_gateway(hass, config, async_add_device_callback): +async def get_gateway(hass, config, async_add_device_callback, + async_connection_status_callback): """Create a gateway object and verify configuration.""" from pydeconz import DeconzSession session = aiohttp_client.async_get_clientsession(hass) deconz = DeconzSession(hass.loop, session, **config, - async_add_device=async_add_device_callback) + async_add_device=async_add_device_callback, + connection_status=async_connection_status_callback) result = await deconz.async_load_parameters() if result: diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 09549a300a0..9ab7b56c0ca 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -6,7 +6,7 @@ "title": "Define deCONZ gateway", "data": { "host": "Host", - "port": "Port (default value: '80')" + "port": "Port" } }, "link": { diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 82a9fefbb71..ad792d035cc 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -182,6 +182,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): setup = await hass.async_add_job( platform.setup_scanner, hass, p_config, tracker.see, disc_info) + elif hasattr(platform, 'async_setup_entry'): + setup = await platform.async_setup_entry( + hass, p_config, tracker.async_see) else: raise HomeAssistantError("Invalid device_tracker platform.") @@ -197,6 +200,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error setting up platform %s", p_type) + hass.data[DOMAIN] = async_setup_platform + setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config in config_per_platform(config, DOMAIN)] if setup_tasks: @@ -230,6 +235,12 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): return True +async def async_setup_entry(hass, entry): + """Set up an entry.""" + await hass.data[DOMAIN](entry.domain, entry) + return True + + class DeviceTracker: """Representation of a device tracker.""" @@ -373,6 +384,7 @@ class DeviceTracker: for device in self.devices.values(): if (device.track and device.last_update_home) and \ device.stale(now): + device.mark_stale() self.hass.async_create_task(device.async_update_ha_state(True)) async def async_setup_tracked_device(self): @@ -528,9 +540,15 @@ class Device(Entity): Async friendly. """ - return self.last_seen and \ + return self.last_seen is None or \ (now or dt_util.utcnow()) - self.last_seen > self.consider_home + def mark_stale(self): + """Mark the device state as stale.""" + self._state = STATE_NOT_HOME + self.gps = None + self.last_update_home = False + async def async_update(self): """Update state of entity. @@ -550,9 +568,7 @@ class Device(Entity): else: self._state = zone_state.name elif self.stale(): - self._state = STATE_NOT_HOME - self.gps = None - self.last_update_home = False + self.mark_stale() else: self._state = STATE_HOME self.last_update_home = True @@ -563,6 +579,7 @@ class Device(Entity): if not state: return self._state = state.state + self.last_update_home = (state.state == STATE_HOME) for attr, var in ( (ATTR_SOURCE_TYPE, 'source_type'), diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 0e8e9bfe98f..4630c3730ca 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -6,43 +6,17 @@ https://home-assistant.io/components/device_tracker.asuswrt/ """ import logging -import voluptuous as vol +from homeassistant.components.asuswrt import DATA_ASUSWRT +from homeassistant.components.device_tracker import DeviceScanner -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, - CONF_PROTOCOL) - -REQUIREMENTS = ['aioasuswrt==1.1.6'] +DEPENDENCIES = ['asuswrt'] _LOGGER = logging.getLogger(__name__) -CONF_PUB_KEY = 'pub_key' -CONF_SSH_KEY = 'ssh_key' -CONF_REQUIRE_IP = 'require_ip' -DEFAULT_SSH_PORT = 22 -SECRET_GROUP = 'Password or SSH Key' - -PLATFORM_SCHEMA = vol.All( - cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY), - PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PROTOCOL, default='ssh'): vol.In(['ssh', 'telnet']), - vol.Optional(CONF_MODE, default='router'): vol.In(['router', 'ap']), - vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port, - vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean, - vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string, - vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile, - vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile - })) - async def async_get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" - scanner = AsusWrtDeviceScanner(config[DOMAIN]) + scanner = AsusWrtDeviceScanner(hass.data[DATA_ASUSWRT]) await scanner.async_connect() return scanner if scanner.success_init else None @@ -51,19 +25,11 @@ class AsusWrtDeviceScanner(DeviceScanner): """This class queries a router running ASUSWRT firmware.""" # Eighth attribute needed for mode (AP mode vs router mode) - def __init__(self, config): + def __init__(self, api): """Initialize the scanner.""" - from aioasuswrt.asuswrt import AsusWrt - self.last_results = {} self.success_init = False - self.connection = AsusWrt(config[CONF_HOST], config[CONF_PORT], - config[CONF_PROTOCOL] == 'telnet', - config[CONF_USERNAME], - config.get(CONF_PASSWORD, ''), - config.get('ssh_key', - config.get('pub_key', '')), - config[CONF_MODE], config[CONF_REQUIRE_IP]) + self.connection = api async def async_connect(self): """Initialize connection to the router.""" diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index 3687571c118..cec494f322c 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -4,129 +4,26 @@ Support for the Geofency platform. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.geofency/ """ -from functools import partial import logging -import voluptuous as vol - -from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.http import HomeAssistantView -from homeassistant.const import ( - ATTR_LATITUDE, ATTR_LONGITUDE, HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify +from homeassistant.components.geofency import TRACKER_UPDATE +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -DEPENDENCIES = ['http'] - -ATTR_CURRENT_LATITUDE = 'currentLatitude' -ATTR_CURRENT_LONGITUDE = 'currentLongitude' - -BEACON_DEV_PREFIX = 'beacon' -CONF_MOBILE_BEACONS = 'mobile_beacons' - -LOCATION_ENTRY = '1' -LOCATION_EXIT = '0' - -URL = '/api/geofency' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MOBILE_BEACONS): vol.All( - cv.ensure_list, [cv.string]), -}) +DEPENDENCIES = ['geofency'] -def setup_scanner(hass, config, see, discovery_info=None): - """Set up an endpoint for the Geofency application.""" - mobile_beacons = config.get(CONF_MOBILE_BEACONS) or [] - - hass.http.register_view(GeofencyView(see, mobile_beacons)) - - return True - - -class GeofencyView(HomeAssistantView): - """View to handle Geofency requests.""" - - url = URL - name = 'api:geofency' - - def __init__(self, see, mobile_beacons): - """Initialize Geofency url endpoints.""" - self.see = see - self.mobile_beacons = [slugify(beacon) for beacon in mobile_beacons] - - async def post(self, request): - """Handle Geofency requests.""" - data = await request.post() - hass = request.app['hass'] - - data = self._validate_data(data) - if not data: - return ("Invalid data", HTTP_UNPROCESSABLE_ENTITY) - - if self._is_mobile_beacon(data): - return await self._set_location(hass, data, None) - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] - else: - location_name = STATE_NOT_HOME - if ATTR_CURRENT_LATITUDE in data: - data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] - data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] - - return await self._set_location(hass, data, location_name) - - @staticmethod - def _validate_data(data): - """Validate POST payload.""" - data = data.copy() - - required_attributes = ['address', 'device', 'entry', - 'latitude', 'longitude', 'name'] - - valid = True - for attribute in required_attributes: - if attribute not in data: - valid = False - _LOGGER.error("'%s' not specified in message", attribute) - - if not valid: - return False - - data['address'] = data['address'].replace('\n', ' ') - data['device'] = slugify(data['device']) - data['name'] = slugify(data['name']) - - gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] - - for attribute in gps_attributes: - if attribute in data: - data[attribute] = float(data[attribute]) - - return data - - def _is_mobile_beacon(self, data): - """Check if we have a mobile beacon.""" - return 'beaconUUID' in data and data['name'] in self.mobile_beacons - - @staticmethod - def _device_name(data): - """Return name of device tracker.""" - if 'beaconUUID' in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) - return data['device'] - - async def _set_location(self, hass, data, location_name): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up the Geofency device tracker.""" + async def _set_location(device, gps, location_name, attributes): """Fire HA event to set location.""" - device = self._device_name(data) + await async_see( + dev_id=device, + gps=gps, + location_name=location_name, + attributes=attributes + ) - await hass.async_add_job( - partial(self.see, dev_id=device, - gps=(data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), - location_name=location_name, - attributes=data)) - - return "Setting location for {}".format(device) + async_dispatcher_connect(hass, TRACKER_UPDATE, _set_location) + return True diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 94a2033e7c0..1995179ff5a 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.7'] +REQUIREMENTS = ['locationsharinglib==3.0.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/googlehome.py b/homeassistant/components/device_tracker/googlehome.py new file mode 100644 index 00000000000..575d9688493 --- /dev/null +++ b/homeassistant/components/device_tracker/googlehome.py @@ -0,0 +1,92 @@ +""" +Support for Google Home bluetooth tacker. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.googlehome/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.components.device_tracker import ( + DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.const import CONF_HOST + +REQUIREMENTS = ['ghlocalapi==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RSSI_THRESHOLD = 'rssi_threshold' + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_RSSI_THRESHOLD, default=-70): vol.Coerce(int), + })) + + +async def async_get_scanner(hass, config): + """Validate the configuration and return an Google Home scanner.""" + scanner = GoogleHomeDeviceScanner(hass, config[DOMAIN]) + await scanner.async_connect() + return scanner if scanner.success_init else None + + +class GoogleHomeDeviceScanner(DeviceScanner): + """This class queries a Google Home unit.""" + + def __init__(self, hass, config): + """Initialize the scanner.""" + from ghlocalapi.device_info import DeviceInfo + from ghlocalapi.bluetooth import Bluetooth + + self.last_results = {} + + self.success_init = False + self._host = config[CONF_HOST] + self.rssi_threshold = config[CONF_RSSI_THRESHOLD] + + session = async_get_clientsession(hass) + self.deviceinfo = DeviceInfo(hass.loop, session, self._host) + self.scanner = Bluetooth(hass.loop, session, self._host) + + async def async_connect(self): + """Initialize connection to Google Home.""" + await self.deviceinfo.get_device_info() + data = self.deviceinfo.device_info + self.success_init = data is not None + + async def async_scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + await self.async_update_info() + return list(self.last_results.keys()) + + async def async_get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + if device not in self.last_results: + return None + return '{}_{}'.format(self._host, + self.last_results[device]['btle_mac_address']) + + async def get_extra_attributes(self, device): + """Return the extra attributes of the device.""" + return self.last_results[device] + + async def async_update_info(self): + """Ensure the information from Google Home is up to date.""" + _LOGGER.debug('Checking Devices...') + await self.scanner.scan_for_devices() + await self.scanner.get_scan_result() + ghname = self.deviceinfo.device_info['name'] + devices = {} + for device in self.scanner.devices: + if device['rssi'] > self.rssi_threshold: + uuid = '{}_{}'.format(self._host, device['mac_address']) + devices[uuid] = {} + devices[uuid]['rssi'] = device['rssi'] + devices[uuid]['btle_mac_address'] = device['mac_address'] + devices[uuid]['ghname'] = ghname + devices[uuid]['source_type'] = 'bluetooth' + self.last_results = devices diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index f479dea184b..30b09834b68 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/device_tracker.luci/ import json import logging import re +from collections import namedtuple import requests import voluptuous as vol @@ -43,14 +44,17 @@ def get_scanner(hass, config): return scanner if scanner.success_init else None +Device = namedtuple('Device', ['mac', 'ip', 'flags', 'device', 'host']) + + class LuciDeviceScanner(DeviceScanner): """This class queries a wireless router running OpenWrt firmware.""" def __init__(self, config): """Initialize the scanner.""" - host = config[CONF_HOST] + self.host = config[CONF_HOST] protocol = 'http' if not config[CONF_SSL] else 'https' - self.origin = '{}://{}'.format(protocol, host) + self.origin = '{}://{}'.format(protocol, self.host) self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] @@ -68,7 +72,7 @@ class LuciDeviceScanner(DeviceScanner): def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" self._update_info() - return self.last_results + return [device.mac 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.""" @@ -88,6 +92,18 @@ class LuciDeviceScanner(DeviceScanner): return return self.mac2name.get(device.upper(), None) + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_att = next(( + { + 'ip': result.ip, + 'flags': result.flags, + 'device': result.device, + 'host': result.host + } for result in self.last_results + if result.mac == device), None) + return filter_att + def _update_info(self): """Ensure the information from the Luci router is up to date. @@ -114,7 +130,11 @@ class LuciDeviceScanner(DeviceScanner): # Check if the Flags for each device contain # NUD_REACHABLE and if so, add it to last_results if int(device_entry['Flags'], 16) & 0x2: - self.last_results.append(device_entry['HW address']) + self.last_results.append(Device(device_entry['HW address'], + device_entry['IP address'], + device_entry['Flags'], + device_entry['Device'], + self.host)) return True diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 320468159e0..5b69c13afa6 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -6,25 +6,30 @@ https://home-assistant.io/components/device_tracker.mikrotik/ """ import logging +import ssl + import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_METHOD) REQUIREMENTS = ['librouteros==2.1.1'] -MTK_DEFAULT_API_PORT = '8728' - _LOGGER = logging.getLogger(__name__) +MTK_DEFAULT_API_PORT = '8728' +MTK_DEFAULT_API_SSL_PORT = '8729' + 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_PORT, default=MTK_DEFAULT_API_PORT): cv.port + vol.Optional(CONF_METHOD): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean }) @@ -42,9 +47,17 @@ class MikrotikScanner(DeviceScanner): self.last_results = {} self.host = config[CONF_HOST] - self.port = config[CONF_PORT] + self.ssl = config[CONF_SSL] + try: + self.port = config[CONF_PORT] + except KeyError: + if self.ssl: + self.port = MTK_DEFAULT_API_SSL_PORT + else: + self.port = MTK_DEFAULT_API_PORT self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] + self.method = config.get(CONF_METHOD) self.connected = False self.success_init = False @@ -53,27 +66,29 @@ class MikrotikScanner(DeviceScanner): self.success_init = self.connect_to_device() if self.success_init: - _LOGGER.info( - "Start polling Mikrotik (%s) router...", - self.host - ) + _LOGGER.info("Start polling Mikrotik (%s) router...", self.host) self._update_info() else: - _LOGGER.error( - "Connection to Mikrotik (%s) failed", - self.host - ) + _LOGGER.error("Connection to Mikrotik (%s) failed", self.host) def connect_to_device(self): """Connect to Mikrotik method.""" import librouteros try: + kwargs = { + 'port': self.port, + 'encoding': 'utf-8' + } + if self.ssl: + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + kwargs['ssl_wrapper'] = ssl_context.wrap_socket self.client = librouteros.connect( self.host, self.username, self.password, - port=int(self.port), - encoding='utf-8' + **kwargs ) try: @@ -86,16 +101,15 @@ class MikrotikScanner(DeviceScanner): raise if routerboard_info: - _LOGGER.info("Connected to Mikrotik %s with IP %s", - routerboard_info[0].get('model', 'Router'), - self.host) + _LOGGER.info( + "Connected to Mikrotik %s with IP %s", + routerboard_info[0].get('model', 'Router'), self.host) self.connected = True try: self.capsman_exist = self.client( - cmd='/caps-man/interface/getall' - ) + cmd='/caps-man/interface/getall') except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError): @@ -103,27 +117,27 @@ class MikrotikScanner(DeviceScanner): if not self.capsman_exist: _LOGGER.info( - 'Mikrotik %s: Not a CAPSman controller. Trying ' - 'local interfaces ', - self.host - ) + "Mikrotik %s: Not a CAPSman controller. Trying " + "local interfaces", self.host) try: self.wireless_exist = self.client( - cmd='/interface/wireless/getall' - ) + cmd='/interface/wireless/getall') except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError): self.wireless_exist = False - if not self.wireless_exist: + if not self.wireless_exist or self.method == 'ip': _LOGGER.info( - 'Mikrotik %s: Wireless adapters not found. Try to ' - 'use DHCP lease table as presence tracker source. ' - 'Please decrease lease time as much as possible.', - self.host - ) + "Mikrotik %s: Wireless adapters not found. Try to " + "use DHCP lease table as presence tracker source. " + "Please decrease lease time as much as possible", + self.host) + if self.method: + _LOGGER.info( + "Mikrotik %s: Manually selected polling method %s", + self.host, self.method) except (librouteros.exceptions.TrapError, librouteros.exceptions.MultiTrapError, @@ -143,28 +157,27 @@ class MikrotikScanner(DeviceScanner): def _update_info(self): """Retrieve latest information from the Mikrotik box.""" - if self.capsman_exist: - devices_tracker = 'capsman' - elif self.wireless_exist: - devices_tracker = 'wireless' + if self.method: + devices_tracker = self.method else: - devices_tracker = 'ip' + if self.capsman_exist: + devices_tracker = 'capsman' + elif self.wireless_exist: + devices_tracker = 'wireless' + else: + devices_tracker = 'ip' _LOGGER.info( "Loading %s devices from Mikrotik (%s) ...", - devices_tracker, - self.host - ) + devices_tracker, self.host) device_names = self.client(cmd='/ip/dhcp-server/lease/getall') if devices_tracker == 'capsman': devices = self.client( - cmd='/caps-man/registration-table/getall' - ) + cmd='/caps-man/registration-table/getall') elif devices_tracker == 'wireless': devices = self.client( - cmd='/interface/wireless/registration-table/getall' - ) + cmd='/interface/wireless/registration-table/getall') else: devices = device_names @@ -172,21 +185,17 @@ class MikrotikScanner(DeviceScanner): return False mac_names = {device.get('mac-address'): device.get('host-name') - for device in device_names - if device.get('mac-address')} + for device in device_names if device.get('mac-address')} - if self.wireless_exist or self.capsman_exist: + if devices_tracker in ('wireless', 'capsman'): self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) - for device in devices - } + for device in devices} else: self.last_results = { device.get('mac-address'): mac_names.get(device.get('mac-address')) - for device in device_names - if device.get('active-address') - } + for device in device_names if device.get('active-address')} return True diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index 49d3f3207ba..8b10bc2b9bb 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -19,11 +19,16 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): return False for device in new_devices: + gateway_id = id(device.gateway) dev_id = ( - id(device.gateway), device.node_id, device.child_id, + gateway_id, device.node_id, device.child_id, device.value_type) async_dispatcher_connect( - hass, mysensors.const.SIGNAL_CALLBACK.format(*dev_id), + hass, mysensors.const.CHILD_CALLBACK.format(*dev_id), + device.async_update_callback) + async_dispatcher_connect( + hass, + mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id), device.async_update_callback) return True diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index 10f71450f69..ae2b9d6146b 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -7,55 +7,29 @@ https://home-assistant.io/components/device_tracker.owntracks/ import base64 import json import logging -from collections import defaultdict -import voluptuous as vol - -from homeassistant.components import mqtt -import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, - SOURCE_TYPE_GPS + ATTR_SOURCE_TYPE, SOURCE_TYPE_BLUETOOTH_LE, SOURCE_TYPE_GPS ) +from homeassistant.components.owntracks import DOMAIN as OT_DOMAIN from homeassistant.const import STATE_HOME -from homeassistant.core import callback from homeassistant.util import slugify, decorator -REQUIREMENTS = ['libnacl==1.6.1'] + +DEPENDENCIES = ['owntracks'] _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() -BEACON_DEV_ID = 'beacon' -CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' -CONF_SECRET = 'secret' -CONF_WAYPOINT_IMPORT = 'waypoints' -CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' -CONF_MQTT_TOPIC = 'mqtt_topic' -CONF_REGION_MAPPING = 'region_mapping' -CONF_EVENTS_ONLY = 'events_only' - -DEPENDENCIES = ['mqtt'] - -DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' -REGION_MAPPING = {} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, - vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, - vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): - mqtt.valid_subscribe_topic, - vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( - cv.ensure_list, [cv.string]), - vol.Optional(CONF_SECRET): vol.Any( - vol.Schema({vol.Optional(cv.string): cv.string}), - cv.string), - vol.Optional(CONF_REGION_MAPPING, default=REGION_MAPPING): dict -}) +async def async_setup_entry(hass, entry, async_see): + """Set up OwnTracks based off an entry.""" + hass.data[OT_DOMAIN]['context'].async_see = async_see + hass.helpers.dispatcher.async_dispatcher_connect( + OT_DOMAIN, async_handle_message) + return True def get_cipher(): @@ -72,29 +46,6 @@ def get_cipher(): return (KEYLEN, decrypt) -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - async def async_handle_mqtt_message(topic, payload, qos): - """Handle incoming OwnTracks message.""" - try: - message = json.loads(payload) - except ValueError: - # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) - return - - message['topic'] = topic - - await async_handle_message(hass, context, message) - - await mqtt.async_subscribe( - hass, context.mqtt_topic, async_handle_mqtt_message, 1) - - return True - - def _parse_topic(topic, subscribe_topic): """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple. @@ -202,93 +153,6 @@ def _decrypt_payload(secret, topic, ciphertext): return None -def context_from_config(async_see, config): - """Create an async context from Home Assistant config.""" - max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - waypoint_import = config.get(CONF_WAYPOINT_IMPORT) - waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) - secret = config.get(CONF_SECRET) - region_mapping = config.get(CONF_REGION_MAPPING) - events_only = config.get(CONF_EVENTS_ONLY) - mqtt_topic = config.get(CONF_MQTT_TOPIC) - - return OwnTracksContext(async_see, secret, max_gps_accuracy, - waypoint_import, waypoint_whitelist, - region_mapping, events_only, mqtt_topic) - - -class OwnTracksContext: - """Hold the current OwnTracks context.""" - - def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints, - waypoint_whitelist, region_mapping, events_only, mqtt_topic): - """Initialize an OwnTracks context.""" - self.async_see = async_see - self.secret = secret - self.max_gps_accuracy = max_gps_accuracy - self.mobile_beacons_active = defaultdict(set) - self.regions_entered = defaultdict(list) - self.import_waypoints = import_waypoints - self.waypoint_whitelist = waypoint_whitelist - self.region_mapping = region_mapping - self.events_only = events_only - self.mqtt_topic = mqtt_topic - - @callback - def async_valid_accuracy(self, message): - """Check if we should ignore this message.""" - acc = message.get('acc') - - if acc is None: - return False - - try: - acc = float(acc) - except ValueError: - return False - - if acc == 0: - _LOGGER.warning( - "Ignoring %s update because GPS accuracy is zero: %s", - message['_type'], message) - return False - - if self.max_gps_accuracy is not None and \ - acc > self.max_gps_accuracy: - _LOGGER.info("Ignoring %s update because expected GPS " - "accuracy %s is not met: %s", - message['_type'], self.max_gps_accuracy, - message) - return False - - return True - - async def async_see_beacons(self, hass, dev_id, kwargs_param): - """Set active beacons to the current location.""" - kwargs = kwargs_param.copy() - - # Mobile beacons should always be set to the location of the - # tracking device. I get the device state and make the necessary - # changes to kwargs. - device_tracker_state = hass.states.get( - "device_tracker.{}".format(dev_id)) - - if device_tracker_state is not None: - acc = device_tracker_state.attributes.get("gps_accuracy") - lat = device_tracker_state.attributes.get("latitude") - lon = device_tracker_state.attributes.get("longitude") - kwargs['gps_accuracy'] = acc - kwargs['gps'] = (lat, lon) - - # the battery state applies to the tracking device, not the beacon - # kwargs location is the beacon's configured lat/lon - kwargs.pop('battery', None) - for beacon in self.mobile_beacons_active[dev_id]: - kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) - kwargs['host_name'] = beacon - await self.async_see(**kwargs) - - @HANDLERS.register('location') async def async_handle_location_message(hass, context, message): """Handle a location message.""" @@ -485,6 +349,8 @@ async def async_handle_message(hass, context, message): """Handle an OwnTracks message.""" msgtype = message.get('_type') + _LOGGER.debug("Received %s", message) + handler = HANDLERS.get(msgtype, async_handle_unsupported_msg) await handler(hass, context, message) diff --git a/homeassistant/components/device_tracker/owntracks_http.py b/homeassistant/components/device_tracker/owntracks_http.py deleted file mode 100644 index b9a813738ad..00000000000 --- a/homeassistant/components/device_tracker/owntracks_http.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Device tracker platform that adds support for OwnTracks over HTTP. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.owntracks_http/ -""" -import re - -from aiohttp.web_exceptions import HTTPInternalServerError - -from homeassistant.components.http import HomeAssistantView - -# pylint: disable=unused-import -from .owntracks import ( # NOQA - REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message) - - -DEPENDENCIES = ['http'] - - -async def async_setup_scanner(hass, config, async_see, discovery_info=None): - """Set up an OwnTracks tracker.""" - context = context_from_config(async_see, config) - - hass.http.register_view(OwnTracksView(context)) - - return True - - -class OwnTracksView(HomeAssistantView): - """View to handle OwnTracks HTTP requests.""" - - url = '/api/owntracks/{user}/{device}' - name = 'api:owntracks' - - def __init__(self, context): - """Initialize OwnTracks URL endpoints.""" - self.context = context - - async def post(self, request, user, device): - """Handle an OwnTracks message.""" - hass = request.app['hass'] - - subscription = self.context.mqtt_topic - topic = re.sub('/#$', '', subscription) - - message = await request.json() - message['topic'] = '{}/{}/{}'.format(topic, user, device) - - try: - await async_handle_message(hass, self.context, message) - return self.json([]) - - except ValueError: - raise HTTPInternalServerError diff --git a/homeassistant/components/device_tracker/sky_hub.py b/homeassistant/components/device_tracker/sky_hub.py index deab486ec6e..0d69e08aa71 100644 --- a/homeassistant/components/device_tracker/sky_hub.py +++ b/homeassistant/components/device_tracker/sky_hub.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) _MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string + vol.Optional(CONF_HOST): cv.string }) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 224aee4363b..81d8a6867c6 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.2'] +REQUIREMENTS = ['pytile==2.0.5'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEVICE_TYPES = ['PHONE', 'TILE'] diff --git a/homeassistant/components/device_tracker/traccar.py b/homeassistant/components/device_tracker/traccar.py new file mode 100644 index 00000000000..982a572fd94 --- /dev/null +++ b/homeassistant/components/device_tracker/traccar.py @@ -0,0 +1,90 @@ +""" +Support for Traccar device tracking. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.traccar/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, + CONF_PASSWORD, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import slugify + + +REQUIREMENTS = ['pytraccar==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_CATEGORY = 'category' +ATTR_GEOFENCE = 'geofence' +ATTR_TRACKER = 'tracker' + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=8082): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, +}) + + +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Validate the configuration and return a Traccar scanner.""" + from pytraccar.api import API + + session = async_get_clientsession(hass, config[CONF_VERIFY_SSL]) + + api = API(hass.loop, session, config[CONF_USERNAME], config[CONF_PASSWORD], + config[CONF_HOST], config[CONF_PORT], config[CONF_SSL]) + scanner = TraccarScanner(api, hass, async_see) + return await scanner.async_init() + + +class TraccarScanner: + """Define an object to retrieve Traccar data.""" + + def __init__(self, api, hass, async_see): + """Initialize.""" + self._async_see = async_see + self._api = api + self._hass = hass + + async def async_init(self): + """Further initialize connection to Traccar.""" + await self._api.test_connection() + if self._api.authenticated: + await self._async_update() + async_track_time_interval(self._hass, + self._async_update, + DEFAULT_SCAN_INTERVAL) + + return self._api.authenticated + + async def _async_update(self, now=None): + """Update info from Traccar.""" + _LOGGER.debug('Updating device data.') + await self._api.get_device_info() + for devicename in self._api.device_info: + device = self._api.device_info[devicename] + device_attributes = { + ATTR_ADDRESS: device['address'], + ATTR_GEOFENCE: device['geofence'], + ATTR_CATEGORY: device['category'], + ATTR_TRACKER: 'traccar' + } + await self._async_see( + dev_id=slugify(device['device_id']), + gps=(device['latitude'], device['longitude']), + attributes=device_attributes) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 1c02efe4489..1abd86ffd8a 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -61,7 +61,8 @@ class XiaomiMiioDeviceScanner(DeviceScanner): devices = [] try: - station_info = await self.hass.async_add_job(self.device.status) + station_info = \ + await self.hass.async_add_executor_job(self.device.status) _LOGGER.debug("Got new station info: %s", station_info) for device in station_info.associated_stations: diff --git a/homeassistant/components/dialogflow/.translations/cs.json b/homeassistant/components/dialogflow/.translations/cs.json new file mode 100644 index 00000000000..21da9b4823b --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Dialogflow.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [integraci Dialogflow]({dialogflow_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Podrobn\u011bj\u0161\u00ed informace naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Dialogflow?", + "title": "Nastavit Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json new file mode 100644 index 00000000000..e10d890b501 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/de.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Dialogflow Webhook einrichten" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json new file mode 100644 index 00000000000..892f0c5bfd0 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.", + "one_instance_allowed": "Solo una instancia es necesaria." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json new file mode 100644 index 00000000000..5a28d6be9ac --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.", + "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." + }, + "step": { + "user": { + "description": "Weet u zeker dat u Dialogflow wilt instellen?", + "title": "Stel de Twilio Dialogflow in" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/zh-Hans.json b/homeassistant/components/dialogflow/.translations/zh-Hans.json new file mode 100644 index 00000000000..6eecbed54ac --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Dialogflow \u7684 Webhook \u96c6\u6210]({dialogflow_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" + }, + "step": { + "user": { + "title": "\u8bbe\u7f6e Dialogflow Webhook" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 900dae5c7c1..3f3fbe7c14e 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -76,7 +76,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'DialogFlow', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index aa7b9973c8e..94248000601 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -11,7 +11,7 @@ import re import voluptuous as vol from homeassistant.const import ( CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD, - CONF_TEMPERATURE_UNIT, CONF_USERNAME, TEMP_FAHRENHEIT) + CONF_TEMPERATURE_UNIT, CONF_USERNAME) from homeassistant.core import HomeAssistant, callback # noqa from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType # noqa DOMAIN = "elkm1" -REQUIREMENTS = ['elkm1-lib==0.7.10'] +REQUIREMENTS = ['elkm1-lib==0.7.12'] CONF_AREA = 'area' CONF_COUNTER = 'counter' @@ -83,17 +83,17 @@ CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_USERNAME, default=''): cv.string, vol.Optional(CONF_PASSWORD, default=''): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT, default=TEMP_FAHRENHEIT): + vol.Optional(CONF_TEMPERATURE_UNIT, default='F'): cv.temperature_unit, - vol.Optional(CONF_AREA): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_COUNTER): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_KEYPAD): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_OUTPUT): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_PLC): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_SETTING): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_TASK): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_THERMOSTAT): CONFIG_SCHEMA_SUBDOMAIN, - vol.Optional(CONF_ZONE): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_AREA, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_COUNTER, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_KEYPAD, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_OUTPUT, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_PLC, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_SETTING, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_TASK, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_THERMOSTAT, default={}): CONFIG_SCHEMA_SUBDOMAIN, + vol.Optional(CONF_ZONE, default={}): CONFIG_SCHEMA_SUBDOMAIN, }, _host_validator, ) diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 35bb92fa610..3462b0bc1eb 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -11,10 +11,10 @@ import logging import voluptuous as vol -from homeassistant.components.fan import ( - DOMAIN, PLATFORM_SCHEMA, SUPPORT_SET_SPEED, FanEntity) -from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN) +from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA, + SUPPORT_SET_SPEED, DOMAIN, ) +from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, + ATTR_ENTITY_ID, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -30,6 +30,7 @@ MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6' MODEL_AIRPURIFIER_V3 = 'zhimi.airpurifier.v3' MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1' MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1' +MODEL_AIRFRESH_VA2 = 'zhimi.airfresh.va2' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -47,8 +48,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zhimi.airpurifier.v3', 'zhimi.airpurifier.v5', 'zhimi.airpurifier.v6', + 'zhimi.airpurifier.mc1', 'zhimi.humidifier.v1', - 'zhimi.humidifier.ca1']), + 'zhimi.humidifier.ca1', + 'zhimi.airfresh.va2', + ]), }) ATTR_MODEL = 'model' @@ -97,6 +101,9 @@ ATTR_SPEED = 'speed' ATTR_DEPTH = 'depth' ATTR_DRY = 'dry' +# Air Fresh +ATTR_CO2 = 'co2' + # Map attributes to properties of the state object AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = { ATTR_TEMPERATURE: 'temperature', @@ -166,31 +173,55 @@ AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = { ATTR_BUTTON_PRESSED: 'button_pressed', } -AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON = { ATTR_TEMPERATURE: 'temperature', ATTR_HUMIDITY: 'humidity', ATTR_MODE: 'mode', ATTR_BUZZER: 'buzzer', ATTR_CHILD_LOCK: 'child_lock', - ATTR_TRANS_LEVEL: 'trans_level', ATTR_TARGET_HUMIDITY: 'target_humidity', ATTR_LED_BRIGHTNESS: 'led_brightness', - ATTR_BUTTON_PRESSED: 'button_pressed', ATTR_USE_TIME: 'use_time', ATTR_HARDWARE_VERSION: 'hardware_version', } +AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = { + **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, + ATTR_TRANS_LEVEL: 'trans_level', + ATTR_BUTTON_PRESSED: 'button_pressed', +} + AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA = { - **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER, + **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON, ATTR_SPEED: 'speed', ATTR_DEPTH: 'depth', ATTR_DRY: 'dry', } +AVAILABLE_ATTRIBUTES_AIRFRESH = { + ATTR_TEMPERATURE: 'temperature', + ATTR_AIR_QUALITY_INDEX: 'aqi', + ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi', + ATTR_CO2: 'co2', + ATTR_HUMIDITY: 'humidity', + ATTR_MODE: 'mode', + ATTR_LED: 'led', + ATTR_LED_BRIGHTNESS: 'led_brightness', + ATTR_BUZZER: 'buzzer', + ATTR_CHILD_LOCK: 'child_lock', + ATTR_FILTER_LIFE: 'filter_life_remaining', + ATTR_FILTER_HOURS_USED: 'filter_hours_used', + ATTR_USE_TIME: 'use_time', + ATTR_MOTOR_SPEED: 'motor_speed', + ATTR_EXTRA_FEATURES: 'extra_features', +} + OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle'] OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite'] OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle', 'Medium', 'High', 'Strong'] +OPERATION_MODES_AIRFRESH = ['Auto', 'Silent', 'Interval', 'Low', + 'Middle', 'Strong'] SUCCESS = ['ok'] @@ -234,6 +265,12 @@ FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_FLAGS_GENERIC | FEATURE_FLAGS_AIRHUMIDIFIER_CA = (FEATURE_FLAGS_AIRHUMIDIFIER | FEATURE_SET_DRY) +FEATURE_FLAGS_AIRFRESH = (FEATURE_FLAGS_GENERIC | + FEATURE_SET_LED | + FEATURE_SET_LED_BRIGHTNESS | + FEATURE_RESET_FILTER | + FEATURE_SET_EXTRA_FEATURES) + SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on' SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off' SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on' @@ -350,6 +387,10 @@ async def async_setup_platform(hass, config, async_add_entities, from miio import AirHumidifier air_humidifier = AirHumidifier(host, token, model=model) device = XiaomiAirHumidifier(name, air_humidifier, model, unique_id) + elif model.startswith('zhimi.airfresh.'): + from miio import AirFresh + air_fresh = AirFresh(host, token) + device = XiaomiAirFresh(name, air_fresh, model, unique_id) else: _LOGGER.error( 'Unsupported device found! Please create an issue at ' @@ -454,7 +495,7 @@ class XiaomiGenericDevice(FanEntity): """Call a miio device command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from miio device: %s", result) @@ -558,7 +599,7 @@ class XiaomiAirPurifier(XiaomiGenericDevice): return try: - state = await self.hass.async_add_job( + state = await self.hass.async_add_executor_job( self._device.status) _LOGGER.debug("Got new state: %s", state) @@ -734,7 +775,7 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): return try: - state = await self.hass.async_add_job(self._device.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -812,3 +853,116 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): await self._try_command( "Turning the dry mode of the miio device off failed.", self._device.set_dry, False) + + +class XiaomiAirFresh(XiaomiGenericDevice): + """Representation of a Xiaomi Air Fresh.""" + + def __init__(self, name, device, model, unique_id): + """Initialize the miio device.""" + super().__init__(name, device, model, unique_id) + + self._device_features = FEATURE_FLAGS_AIRFRESH + self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH + self._speed_list = OPERATION_MODES_AIRFRESH + self._state_attrs.update( + {attribute: None for attribute in self._available_attributes}) + + async def async_update(self): + """Fetch state from the device.""" + from miio import DeviceException + + # On state change the device doesn't provide the new state immediately. + if self._skip_update: + self._skip_update = False + return + + try: + state = await self.hass.async_add_executor_job( + self._device.status) + _LOGGER.debug("Got new state: %s", state) + + self._available = True + self._state = state.is_on + self._state_attrs.update( + {key: self._extract_value_from_attribute(state, value) for + key, value in self._available_attributes.items()}) + + except DeviceException as ex: + self._available = False + _LOGGER.error("Got exception while fetching the state: %s", ex) + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self._speed_list + + @property + def speed(self): + """Return the current speed.""" + if self._state: + from miio.airfresh import OperationMode + + return OperationMode(self._state_attrs[ATTR_MODE]).name + + return None + + async def async_set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if self.supported_features & SUPPORT_SET_SPEED == 0: + return + + from miio.airfresh import OperationMode + + _LOGGER.debug("Setting the operation mode to: %s", speed) + + await self._try_command( + "Setting operation mode of the miio device failed.", + self._device.set_mode, OperationMode[speed.title()]) + + async def async_set_led_on(self): + """Turn the led on.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, True) + + async def async_set_led_off(self): + """Turn the led off.""" + if self._device_features & FEATURE_SET_LED == 0: + return + + await self._try_command( + "Turning the led of the miio device off failed.", + self._device.set_led, False) + + async def async_set_led_brightness(self, brightness: int = 2): + """Set the led brightness.""" + if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0: + return + + from miio.airfresh import LedBrightness + + await self._try_command( + "Setting the led brightness of the miio device failed.", + self._device.set_led_brightness, LedBrightness(brightness)) + + async def async_set_extra_features(self, features: int = 1): + """Set the extra features.""" + if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0: + return + + await self._try_command( + "Setting the extra features of the miio device failed.", + self._device.set_extra_features, features) + + async def async_reset_filter(self): + """Reset the filter lifetime and usage.""" + if self._device_features & FEATURE_RESET_FILTER == 0: + return + + await self._try_command( + "Resetting the filter lifetime of the miio device failed.", + self._device.reset_filter) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py new file mode 100644 index 00000000000..c9dd19b4bc8 --- /dev/null +++ b/homeassistant/components/fibaro.py @@ -0,0 +1,351 @@ +""" +Support for the Fibaro devices. + +For more details about this platform, please refer to the documentation. +https://home-assistant.io/components/fibaro/ +""" + +import logging +from collections import defaultdict +import voluptuous as vol + +from homeassistant.const import (ATTR_ARMED, ATTR_BATTERY_LEVEL, + CONF_PASSWORD, CONF_URL, CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import convert, slugify +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['fiblary3==0.1.7'] + +_LOGGER = logging.getLogger(__name__) +DOMAIN = 'fibaro' +FIBARO_DEVICES = 'fibaro_devices' +FIBARO_CONTROLLER = 'fibaro_controller' +ATTR_CURRENT_POWER_W = "current_power_w" +ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" +CONF_PLUGINS = "plugins" + +FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', 'sensor', 'switch'] + +FIBARO_TYPEMAP = { + 'com.fibaro.multilevelSensor': "sensor", + 'com.fibaro.binarySwitch': 'switch', + 'com.fibaro.multilevelSwitch': 'switch', + 'com.fibaro.FGD212': 'light', + 'com.fibaro.FGR': 'cover', + 'com.fibaro.doorSensor': 'binary_sensor', + 'com.fibaro.doorWindowSensor': 'binary_sensor', + 'com.fibaro.FGMS001': 'binary_sensor', + 'com.fibaro.heatDetector': 'binary_sensor', + 'com.fibaro.lifeDangerSensor': 'binary_sensor', + 'com.fibaro.smokeSensor': 'binary_sensor', + 'com.fibaro.remoteSwitch': 'switch', + 'com.fibaro.sensor': 'sensor', + 'com.fibaro.colorController': 'light' +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_URL): cv.url, + vol.Optional(CONF_PLUGINS, default=False): cv.boolean, + }) +}, extra=vol.ALLOW_EXTRA) + + +class FibaroController(): + """Initiate Fibaro Controller Class.""" + + _room_map = None # Dict for mapping roomId to room object + _device_map = None # Dict for mapping deviceId to device object + fibaro_devices = None # List of devices by type + _callbacks = {} # Dict of update value callbacks by deviceId + _client = None # Fiblary's Client object for communication + _state_handler = None # Fiblary's StateHandler object + _import_plugins = None # Whether to import devices from plugins + + def __init__(self, username, password, url, import_plugins): + """Initialize the Fibaro controller.""" + from fiblary3.client.v4.client import Client as FibaroClient + self._client = FibaroClient(url, username, password) + + def connect(self): + """Start the communication with the Fibaro controller.""" + try: + login = self._client.login.get() + except AssertionError: + _LOGGER.error("Can't connect to Fibaro HC. " + "Please check URL.") + return False + if login is None or login.status is False: + _LOGGER.error("Invalid login for Fibaro HC. " + "Please check username and password.") + return False + + self._room_map = {room.id: room for room in self._client.rooms.list()} + self._read_devices() + return True + + def enable_state_handler(self): + """Start StateHandler thread for monitoring updates.""" + from fiblary3.client.v4.client import StateHandler + self._state_handler = StateHandler(self._client, self._on_state_change) + + def disable_state_handler(self): + """Stop StateHandler thread used for monitoring updates.""" + self._state_handler.stop() + self._state_handler = None + + def _on_state_change(self, state): + """Handle change report received from the HomeCenter.""" + callback_set = set() + for change in state.get('changes', []): + dev_id = change.pop('id') + for property_name, value in change.items(): + if property_name == 'log': + if value and value != "transfer OK": + _LOGGER.debug("LOG %s: %s", + self._device_map[dev_id].friendly_name, + value) + continue + if property_name == 'logTemp': + continue + if property_name in self._device_map[dev_id].properties: + self._device_map[dev_id].properties[property_name] = \ + value + _LOGGER.debug("<- %s.%s = %s", + self._device_map[dev_id].ha_id, + property_name, + str(value)) + else: + _LOGGER.warning("Error updating %s data of %s, not found", + property_name, + self._device_map[dev_id].ha_id) + if dev_id in self._callbacks: + callback_set.add(dev_id) + for item in callback_set: + self._callbacks[item]() + + def register(self, device_id, callback): + """Register device with a callback for updates.""" + self._callbacks[device_id] = callback + + @staticmethod + def _map_device_to_type(device): + """Map device to HA device type.""" + # Use our lookup table to identify device type + device_type = FIBARO_TYPEMAP.get( + device.type, FIBARO_TYPEMAP.get(device.baseType)) + + # We can also identify device type by its capabilities + if device_type is None: + if 'setBrightness' in device.actions: + device_type = 'light' + elif 'turnOn' in device.actions: + device_type = 'switch' + elif 'open' in device.actions: + device_type = 'cover' + elif 'value' in device.properties: + if device.properties.value in ('true', 'false'): + device_type = 'binary_sensor' + else: + device_type = 'sensor' + + # Switches that control lights should show up as lights + if device_type == 'switch' and \ + 'isLight' in device.properties and \ + device.properties.isLight == 'true': + device_type = 'light' + return device_type + + def _read_devices(self): + """Read and process the device list.""" + devices = self._client.devices.list() + self._device_map = {} + for device in devices: + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.friendly_name = room_name + ' ' + device.name + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + self._device_map[device.id] = device + self.fibaro_devices = defaultdict(list) + for device in self._device_map.values(): + if device.enabled and \ + (not device.isPlugin or self._import_plugins): + device.mapped_type = self._map_device_to_type(device) + if device.mapped_type: + self.fibaro_devices[device.mapped_type].append(device) + else: + _LOGGER.debug("%s (%s, %s) not mapped", + device.ha_id, device.type, + device.baseType) + + +def setup(hass, config): + """Set up the Fibaro Component.""" + hass.data[FIBARO_CONTROLLER] = controller = \ + FibaroController(config[DOMAIN][CONF_USERNAME], + config[DOMAIN][CONF_PASSWORD], + config[DOMAIN][CONF_URL], + config[DOMAIN][CONF_PLUGINS]) + + def stop_fibaro(event): + """Stop Fibaro Thread.""" + _LOGGER.info("Shutting down Fibaro connection") + hass.data[FIBARO_CONTROLLER].disable_state_handler() + + if controller.connect(): + hass.data[FIBARO_DEVICES] = controller.fibaro_devices + for component in FIBARO_COMPONENTS: + discovery.load_platform(hass, component, DOMAIN, {}, config) + controller.enable_state_handler() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro) + return True + + return False + + +class FibaroDevice(Entity): + """Representation of a Fibaro device entity.""" + + def __init__(self, fibaro_device, controller): + """Initialize the device.""" + self.fibaro_device = fibaro_device + self.controller = controller + self._name = fibaro_device.friendly_name + self.ha_id = fibaro_device.ha_id + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.controller.register(self.fibaro_device.id, self._update_callback) + + def _update_callback(self): + """Update the state.""" + self.schedule_update_ha_state(True) + + @property + def level(self): + """Get the level of Fibaro device.""" + if 'value' in self.fibaro_device.properties: + return self.fibaro_device.properties.value + return None + + @property + def level2(self): + """Get the tilt level of Fibaro device.""" + if 'value2' in self.fibaro_device.properties: + return self.fibaro_device.properties.value2 + return None + + def dont_know_message(self, action): + """Make a warning in case we don't know how to perform an action.""" + _LOGGER.warning("Not sure how to setValue: %s " + "(available actions: %s)", str(self.ha_id), + str(self.fibaro_device.actions)) + + def set_level(self, level): + """Set the level of Fibaro device.""" + self.action("setValue", level) + if 'value' in self.fibaro_device.properties: + self.fibaro_device.properties.value = level + if 'brightness' in self.fibaro_device.properties: + self.fibaro_device.properties.brightness = level + + def set_level2(self, level): + """Set the level2 of Fibaro device.""" + self.action("setValue2", level) + if 'value2' in self.fibaro_device.properties: + self.fibaro_device.properties.value2 = level + + def call_turn_on(self): + """Turn on the Fibaro device.""" + self.action("turnOn") + + def call_turn_off(self): + """Turn off the Fibaro device.""" + self.action("turnOff") + + def call_set_color(self, red, green, blue, white): + """Set the color of Fibaro device.""" + color_str = "{},{},{},{}".format(int(red), int(green), + int(blue), int(white)) + self.fibaro_device.properties.color = color_str + self.action("setColor", str(int(red)), str(int(green)), + str(int(blue)), str(int(white))) + + def action(self, cmd, *args): + """Perform an action on the Fibaro HC.""" + if cmd in self.fibaro_device.actions: + getattr(self.fibaro_device, cmd)(*args) + _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), + str(cmd), str(args)) + else: + self.dont_know_message(cmd) + + @property + def hidden(self) -> bool: + """Return True if the entity should be hidden from UIs.""" + return self.fibaro_device.visible is False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if 'power' in self.fibaro_device.properties: + power = self.fibaro_device.properties.power + if power: + return convert(power, float, 0.0) + else: + return None + + @property + def current_binary_state(self): + """Return the current binary state.""" + if self.fibaro_device.properties.value == 'false': + return False + if self.fibaro_device.properties.value == 'true' or \ + int(self.fibaro_device.properties.value) > 0: + return True + return False + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def should_poll(self): + """Get polling requirement from fibaro device.""" + return False + + def update(self): + """Call to update state.""" + pass + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = {} + + try: + if 'battery' in self.fibaro_device.interfaces: + attr[ATTR_BATTERY_LEVEL] = \ + int(self.fibaro_device.properties.batteryLevel) + if 'fibaroAlarmArm' in self.fibaro_device.interfaces: + attr[ATTR_ARMED] = bool(self.fibaro_device.properties.armed) + if 'power' in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_POWER_W] = convert( + self.fibaro_device.properties.power, float, 0.0) + if 'energy' in self.fibaro_device.interfaces: + attr[ATTR_CURRENT_ENERGY_KWH] = convert( + self.fibaro_device.properties.energy, float, 0.0) + except (ValueError, KeyError): + pass + + attr['id'] = self.ha_id + return attr diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1d6721306fd..d8ea057a4f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181103.3'] +REQUIREMENTS = ['home-assistant-frontend==20181121.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py new file mode 100644 index 00000000000..92f8f475e65 --- /dev/null +++ b/homeassistant/components/geofency/__init__.py @@ -0,0 +1,146 @@ +""" +Support for Geofency. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/geofency/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME, \ + ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import slugify + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'geofency' +DEPENDENCIES = ['http'] + +CONF_MOBILE_BEACONS = 'mobile_beacons' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_MOBILE_BEACONS, default=[]): vol.All( + cv.ensure_list, + [cv.string] + ), + }), +}, extra=vol.ALLOW_EXTRA) + +ATTR_CURRENT_LATITUDE = 'currentLatitude' +ATTR_CURRENT_LONGITUDE = 'currentLongitude' + +BEACON_DEV_PREFIX = 'beacon' + +LOCATION_ENTRY = '1' +LOCATION_EXIT = '0' + +URL = '/api/geofency' + +TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN) + + +async def async_setup(hass, hass_config): + """Set up the Geofency component.""" + config = hass_config[DOMAIN] + mobile_beacons = config[CONF_MOBILE_BEACONS] + hass.data[DOMAIN] = [slugify(beacon) for beacon in mobile_beacons] + hass.http.register_view(GeofencyView(hass.data[DOMAIN])) + + hass.async_create_task( + async_load_platform(hass, 'device_tracker', DOMAIN, {}, hass_config) + ) + return True + + +class GeofencyView(HomeAssistantView): + """View to handle Geofency requests.""" + + url = URL + name = 'api:geofency' + + def __init__(self, mobile_beacons): + """Initialize Geofency url endpoints.""" + self.mobile_beacons = mobile_beacons + + async def post(self, request): + """Handle Geofency requests.""" + data = await request.post() + hass = request.app['hass'] + + data = self._validate_data(data) + if not data: + return "Invalid data", HTTP_UNPROCESSABLE_ENTITY + + if self._is_mobile_beacon(data): + return await self._set_location(hass, data, None) + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] + else: + location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] + + return await self._set_location(hass, data, location_name) + + @staticmethod + def _validate_data(data): + """Validate POST payload.""" + data = data.copy() + + required_attributes = ['address', 'device', 'entry', + 'latitude', 'longitude', 'name'] + + valid = True + for attribute in required_attributes: + if attribute not in data: + valid = False + _LOGGER.error("'%s' not specified in message", attribute) + + if not valid: + return {} + + data['address'] = data['address'].replace('\n', ' ') + data['device'] = slugify(data['device']) + data['name'] = slugify(data['name']) + + gps_attributes = [ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_CURRENT_LATITUDE, ATTR_CURRENT_LONGITUDE] + + for attribute in gps_attributes: + if attribute in data: + data[attribute] = float(data[attribute]) + + return data + + def _is_mobile_beacon(self, data): + """Check if we have a mobile beacon.""" + return 'beaconUUID' in data and data['name'] in self.mobile_beacons + + @staticmethod + def _device_name(data): + """Return name of device tracker.""" + if 'beaconUUID' in data: + return "{}_{}".format(BEACON_DEV_PREFIX, data['name']) + return data['device'] + + async def _set_location(self, hass, data, location_name): + """Fire HA event to set location.""" + device = self._device_name(data) + + async_dispatcher_send( + hass, + TRACKER_UPDATE, + device, + (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), + location_name, + data + ) + + return "Setting location for {}".format(device) diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 8d4ac9f01c9..f444974bc8d 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -24,7 +24,8 @@ from .const import ( DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT, CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY, SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG, - CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT + CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK, + DEFAULT_ALLOW_UNLOCK ) from .http import async_register_http @@ -48,7 +49,9 @@ GOOGLE_ASSISTANT_SCHEMA = vol.Schema({ vol.Optional(CONF_EXPOSED_DOMAINS, default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list, vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA} + vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA}, + vol.Optional(CONF_ALLOW_UNLOCK, + default=DEFAULT_ALLOW_UNLOCK): cv.boolean }, extra=vol.PREVENT_EXTRA) CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 2f54ee33f77..aca960f9c0a 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -11,12 +11,14 @@ CONF_PROJECT_ID = 'project_id' CONF_ALIASES = 'aliases' CONF_API_KEY = 'api_key' CONF_ROOM_HINT = 'room' +CONF_ALLOW_UNLOCK = 'allow_unlock' DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light', - 'media_player', 'scene', 'script', 'switch', 'vacuum', + 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock', ] +DEFAULT_ALLOW_UNLOCK = False CLIMATE_MODE_HEATCOOL = 'heatcool' CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL} @@ -27,6 +29,7 @@ TYPE_VACUUM = PREFIX_TYPES + 'VACUUM' TYPE_SCENE = PREFIX_TYPES + 'SCENE' TYPE_FAN = PREFIX_TYPES + 'FAN' TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT' +TYPE_LOCK = PREFIX_TYPES + 'LOCK' SERVICE_REQUEST_SYNC = 'request_sync' HOMEGRAPH_URL = 'https://homegraph.googleapis.com/' @@ -40,3 +43,4 @@ ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange" ERR_NOT_SUPPORTED = "notSupported" ERR_PROTOCOL_ERROR = 'protocolError' ERR_UNKNOWN_ERROR = 'unknownError' +ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ef6ae109eb5..e71756d9fee 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -16,8 +16,10 @@ class SmartHomeError(Exception): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, agent_user_id, entity_config=None): + def __init__(self, should_expose, agent_user_id, entity_config=None, + allow_unlock=False): """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 diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 65af7b932b0..f29e8bbae12 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -11,6 +11,7 @@ from aiohttp.web import Request, Response # Typing imports from homeassistant.components.http import HomeAssistantView from homeassistant.core import callback +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, @@ -38,11 +39,14 @@ def async_register_http(hass, cfg): # Ignore entities that are views return False + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + explicit_expose = \ entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default and entity.domain in exposed_domains + expose_by_default or entity.domain in exposed_domains # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 633e6258c03..bab63bdb7ae 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -7,7 +7,9 @@ from homeassistant.util.decorator import Registry from homeassistant.core import callback from homeassistant.const import ( - CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES) + CLOUD_NEVER_EXPOSED_ENTITIES, CONF_NAME, STATE_UNAVAILABLE, + ATTR_SUPPORTED_FEATURES +) from homeassistant.components import ( climate, cover, @@ -15,6 +17,7 @@ from homeassistant.components import ( group, input_boolean, light, + lock, media_player, scene, script, @@ -22,12 +25,13 @@ from homeassistant.components import ( vacuum, ) + from . import trait from .const import ( - TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM, + TYPE_LIGHT, TYPE_LOCK, TYPE_SCENE, TYPE_SWITCH, TYPE_VACUUM, TYPE_THERMOSTAT, TYPE_FAN, CONF_ALIASES, CONF_ROOM_HINT, - ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, + ERR_FUNCTION_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR ) from .helpers import SmartHomeError @@ -42,6 +46,7 @@ DOMAIN_TO_GOOGLE_TYPES = { group.DOMAIN: TYPE_SWITCH, input_boolean.DOMAIN: TYPE_SWITCH, light.DOMAIN: TYPE_LIGHT, + lock.DOMAIN: TYPE_LOCK, media_player.DOMAIN: TYPE_SWITCH, scene.DOMAIN: TYPE_SCENE, script.DOMAIN: TYPE_SCENE, @@ -80,7 +85,7 @@ class _GoogleEntity: domain = state.domain features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - return [Trait(self.hass, state) for Trait in trait.TRAITS + return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS if Trait.supported(domain, features)] @callback @@ -168,7 +173,7 @@ class _GoogleEntity: if not executed: raise SmartHomeError( - ERR_NOT_SUPPORTED, + ERR_FUNCTION_NOT_SUPPORTED, 'Unable to execute {} for {}'.format(command, self.state.entity_id)) @@ -232,6 +237,9 @@ async def async_devices_sync(hass, config, payload): """ devices = [] for state in hass.states.async_all(): + if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + continue + if not config.should_expose(state): continue diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 00a01f262a9..d32dd91a3c1 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,7 +1,6 @@ """Implement the Smart Home traits.""" import logging -from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.components import ( climate, cover, @@ -10,6 +9,7 @@ from homeassistant.components import ( input_boolean, media_player, light, + lock, scene, script, switch, @@ -19,13 +19,14 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_LOCKED, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ) +from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.util import color as color_util, temperature as temp_util - from .const import ERR_VALUE_OUT_OF_RANGE from .helpers import SmartHomeError @@ -40,6 +41,8 @@ TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum' TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature' TRAIT_SCENE = PREFIX_TRAITS + 'Scene' TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' +TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' +TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -54,6 +57,8 @@ COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = ( COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = ( PREFIX_COMMANDS + 'ThermostatTemperatureSetRange') COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' +COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' +COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' TRAITS = [] @@ -77,10 +82,11 @@ class _Trait: commands = [] - def __init__(self, hass, state): + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass self.state = state + self.config = config def sync_attributes(self): """Return attributes for a sync request.""" @@ -628,3 +634,119 @@ class TemperatureSettingTrait(_Trait): climate.ATTR_OPERATION_MODE: self.google_to_hass[params['thermostatMode']], }, blocking=True) + + +@register_trait +class LockUnlockTrait(_Trait): + """Trait to lock or unlock a lock. + + https://developers.google.com/actions/smarthome/traits/lockunlock + """ + + name = TRAIT_LOCKUNLOCK + commands = [ + COMMAND_LOCKUNLOCK + ] + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + return domain == lock.DOMAIN + + def sync_attributes(self): + """Return LockUnlock attributes for a sync request.""" + return {} + + def query_attributes(self): + """Return LockUnlock query attributes.""" + return {'isLocked': self.state.state == STATE_LOCKED} + + def can_execute(self, command, params): + """Test if command can be executed.""" + allowed_unlock = not params['lock'] and self.config.allow_unlock + return params['lock'] or allowed_unlock + + async def execute(self, command, params): + """Execute an LockUnlock command.""" + if params['lock']: + service = lock.SERVICE_LOCK + else: + service = lock.SERVICE_UNLOCK + + await self.hass.services.async_call(lock.DOMAIN, service, { + ATTR_ENTITY_ID: self.state.entity_id + }, blocking=True) + + +@register_trait +class FanSpeedTrait(_Trait): + """Trait to control speed of Fan. + + https://developers.google.com/actions/smarthome/traits/fanspeed + """ + + name = TRAIT_FANSPEED + commands = [ + COMMAND_FANSPEED + ] + + speed_synonyms = { + fan.SPEED_OFF: ['stop', 'off'], + fan.SPEED_LOW: ['slow', 'low', 'slowest', 'lowest'], + fan.SPEED_MEDIUM: ['medium', 'mid', 'middle'], + fan.SPEED_HIGH: [ + 'high', 'max', 'fast', 'highest', 'fastest', 'maximum' + ] + } + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != fan.DOMAIN: + return False + + return features & fan.SUPPORT_SET_SPEED + + def sync_attributes(self): + """Return speed point and modes attributes for a sync request.""" + modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, []) + speeds = [] + for mode in modes: + speed = { + "speed_name": mode, + "speed_values": [{ + "speed_synonym": self.speed_synonyms.get(mode), + "lang": 'en' + }] + } + speeds.append(speed) + + return { + 'availableFanSpeeds': { + 'speeds': speeds, + 'ordered': True + }, + "reversible": bool(self.state.attributes.get( + ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION) + } + + def query_attributes(self): + """Return speed point and modes query attributes.""" + attrs = self.state.attributes + response = {} + + speed = attrs.get(fan.ATTR_SPEED) + if speed is not None: + response['on'] = speed != fan.SPEED_OFF + response['online'] = True + response['currentFanSpeedSetting'] = speed + + return response + + async def execute(self, command, 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) diff --git a/homeassistant/components/hangouts/.translations/cs.json b/homeassistant/components/hangouts/.translations/cs.json new file mode 100644 index 00000000000..badd381f2be --- /dev/null +++ b/homeassistant/components/hangouts/.translations/cs.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "already_configured": "Slu\u017eba Google Hangouts je ji\u017e nakonfigurov\u00e1na", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "invalid_2fa": "Dfoufaktorov\u00e9 ov\u011b\u0159en\u00ed se nezda\u0159ilo. Zkuste to znovu.", + "invalid_2fa_method": "Neplatn\u00e1 metoda 2FA (ov\u011b\u0159en\u00ed na telefonu).", + "invalid_login": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed jm\u00e9no, pros\u00edm zkuste to znovu." + }, + "step": { + "2fa": { + "data": { + "2fa": "Dvoufaktorov\u00fd ov\u011b\u0159ovac\u00ed k\u00f3d" + }, + "title": "Dvoufaktorov\u00e9 ov\u011b\u0159en\u00ed" + }, + "user": { + "data": { + "email": "E-mailov\u00e1 adresa", + "password": "Heslo" + }, + "title": "P\u0159ihl\u00e1\u0161en\u00ed do slu\u017eby Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json new file mode 100644 index 00000000000..4b7ad390ceb --- /dev/null +++ b/homeassistant/components/hangouts/.translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Google Hangouts ya est\u00e1 configurado", + "unknown": "Error desconocido" + }, + "error": { + "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, por favor, int\u00e9ntelo de nuevo.", + "invalid_2fa_method": "M\u00e9todo 2FA no v\u00e1lido (verificar en el tel\u00e9fono).", + "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo." + }, + "step": { + "2fa": { + "data": { + "2fa": "Pin 2FA" + }, + "description": "Vac\u00edo", + "title": "Autenticaci\u00f3n de 2 factores" + }, + "user": { + "data": { + "email": "Correo electr\u00f3nico", + "password": "Contrase\u00f1a" + }, + "description": "Vac\u00edo", + "title": "Usuario Google Hangouts" + } + }, + "title": "Google Hangouts" + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 5d8a167d2d9..01d81cc466c 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -22,7 +22,7 @@ from .const import ( SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS, EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, - INTENT_HELP) + INTENT_HELP, SERVICE_RECONNECT) # We need an import from .config_flow, without it .config_flow is never loaded. from .config_flow import HangoutsFlowHandler # noqa: F401 @@ -130,6 +130,12 @@ async def async_setup_entry(hass, config): async_handle_update_users_and_conversations, schema=vol.Schema({})) + hass.services.async_register(DOMAIN, + SERVICE_RECONNECT, + bot. + async_handle_reconnect, + schema=vol.Schema({})) + intent.async_register(hass, HelpIntent(hass)) return True diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 5a527fae260..cf5374c317e 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -39,6 +39,7 @@ CONF_CONVERSATION_NAME = 'name' SERVICE_SEND_MESSAGE = 'send_message' SERVICE_UPDATE = 'update' +SERVICE_RECONNECT = 'reconnect' TARGETS_SCHEMA = vol.All( diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index ed041a30ce6..748079452d8 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -313,6 +313,11 @@ class HangoutsBot: """Handle the update_users_and_conversations service.""" await self._async_list_conversations() + async def async_handle_reconnect(self, _=None): + """Handle the reconnect service.""" + await self.async_disconnect() + await self.async_connect() + def get_intents(self, conv_id): """Return the intents for a specific conversation.""" return self._conversation_intents.get(conv_id) diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml index d07f1d65688..26a7193493b 100644 --- a/homeassistant/components/hangouts/services.yaml +++ b/homeassistant/components/hangouts/services.yaml @@ -1,5 +1,5 @@ update: - description: Updates the list of users and conversations. + description: Updates the list of conversations. send_message: description: Send a notification to a specific target. @@ -13,3 +13,6 @@ send_message: data: description: Other options ['image_file' / 'image_url'] example: '{ "image_file": "file" } or { "image_url": "url" }' + +reconnect: + description: Reconnect the bot. \ No newline at end of file diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 8523bb5ea64..6bfcaaa5d85 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -10,6 +10,7 @@ import os import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) @@ -18,6 +19,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow +from homeassistant.exceptions import HomeAssistantError from .auth import async_setup_auth from .handler import HassIO, HassioAPIError @@ -143,6 +145,7 @@ async def async_check_config(hass): 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'] @@ -179,8 +182,14 @@ async def async_setup(hass, config): if user and user.refresh_tokens: refresh_token = list(user.refresh_tokens.values())[0] + # Migrate old hass.io users to be admin. + if not user.is_admin: + await hass.auth.async_update_user( + user, group_ids=[GROUP_ID_ADMIN]) + if refresh_token is None: - user = await hass.auth.async_create_system_user('Hass.io') + user = await hass.auth.async_create_system_user( + 'Hass.io', [GROUP_ID_ADMIN]) refresh_token = await hass.auth.async_create_refresh_token(user) data['hassio_user'] = user.id await store.async_save(data) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 1c30de918e3..da8daf50f2a 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -22,14 +22,15 @@ from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry from .const import ( BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, - CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO, + CONF_FILTER, CONF_SAFE_MODE, DEFAULT_AUTO_START, DEFAULT_PORT, + DEFAULT_SAFE_MODE, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE) from .util import ( show_setup_message, validate_entity_config, validate_media_player_features) -REQUIREMENTS = ['HAP-python==2.2.2'] +REQUIREMENTS = ['HAP-python==2.4.1'] _LOGGER = logging.getLogger(__name__) @@ -58,6 +59,7 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean, + vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean, vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, }) @@ -73,11 +75,12 @@ async def async_setup(hass, config): port = conf[CONF_PORT] ip_address = conf.get(CONF_IP_ADDRESS) auto_start = conf[CONF_AUTO_START] + safe_mode = conf[CONF_SAFE_MODE] entity_filter = conf[CONF_FILTER] entity_config = conf[CONF_ENTITY_CONFIG] homekit = HomeKit(hass, name, port, ip_address, entity_filter, - entity_config) + entity_config, safe_mode) await hass.async_add_executor_job(homekit.setup) if auto_start: @@ -170,7 +173,8 @@ def get_accessory(hass, driver, state, aid, config): switch_type = config.get(CONF_TYPE, TYPE_SWITCH) a_type = SWITCH_TYPES[switch_type] - elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): + elif state.domain in ('automation', 'input_boolean', 'remote', 'scene', + 'script'): a_type = 'Switch' elif state.domain == 'water_heater': @@ -195,7 +199,7 @@ class HomeKit(): """Class to handle all actions between HomeKit and Home Assistant.""" def __init__(self, hass, name, port, ip_address, entity_filter, - entity_config): + entity_config, safe_mode): """Initialize a HomeKit object.""" self.hass = hass self._name = name @@ -203,6 +207,7 @@ class HomeKit(): self._ip_address = ip_address self._filter = entity_filter self._config = entity_config + self._safe_mode = safe_mode self.status = STATUS_READY self.bridge = None @@ -220,6 +225,9 @@ class HomeKit(): self.driver = HomeDriver(self.hass, address=ip_addr, port=self._port, persist_file=path) self.bridge = HomeBridge(self.hass, self.driver, self._name) + if self._safe_mode: + _LOGGER.debug('Safe_mode selected') + self.driver.safe_mode = True def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index d35d38c6455..d0e3d52b363 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -15,10 +15,12 @@ CONF_ENTITY_CONFIG = 'entity_config' CONF_FEATURE = 'feature' CONF_FEATURE_LIST = 'feature_list' CONF_FILTER = 'filter' +CONF_SAFE_MODE = 'safe_mode' # #### Config Defaults #### DEFAULT_AUTO_START = True DEFAULT_PORT = 51827 +DEFAULT_SAFE_MODE = False # #### Features #### FEATURE_ON_OFF = 'on_off' @@ -127,6 +129,7 @@ CHAR_VALVE_TYPE = 'ValveType' # #### Properties #### PROP_MAX_VALUE = 'maxValue' PROP_MIN_VALUE = 'minValue' +PROP_MIN_STEP = 'minStep' PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} # #### Device Classes #### diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 840800f730b..b3beb11c8b6 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -46,10 +46,12 @@ class GarageDoorOpener(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} if value == 0: - self.char_current_state.set_value(3) + if self.char_current_state.value != value: + self.char_current_state.set_value(3) self.call_service(DOMAIN, SERVICE_OPEN_COVER, params) elif value == 1: - self.char_current_state.set_value(2) + if self.char_current_state.value != value: + self.char_current_state.set_value(2) self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params) def update_state(self, new_state): diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 288da65a4af..b41e1a01543 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -2,12 +2,15 @@ import logging from pyhap.const import ( - CATEGORY_OUTLET, CATEGORY_SWITCH) + CATEGORY_FAUCET, CATEGORY_OUTLET, CATEGORY_SHOWER_HEAD, + CATEGORY_SPRINKLER, CATEGORY_SWITCH) +from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, CONF_TYPE, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) from homeassistant.core import split_entity_id +from homeassistant.helpers.event import call_later from . import TYPES from .accessories import HomeAccessory @@ -18,10 +21,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -CATEGORY_SPRINKLER = 28 -CATEGORY_FAUCET = 29 -CATEGORY_SHOWER_HEAD = 30 - VALVE_TYPE = { TYPE_FAUCET: (CATEGORY_FAUCET, 3), TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2), @@ -74,21 +73,50 @@ class Switch(HomeAccessory): self._domain = split_entity_id(self.entity_id)[0] self._flag_state = False + self.activate_only = self.is_activate( + self.hass.states.get(self.entity_id)) + serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( CHAR_ON, value=False, setter_callback=self.set_state) + def is_activate(self, state): + """Check if entity is activate only.""" + can_cancel = state.attributes.get(ATTR_CAN_CANCEL) + if self._domain == 'scene': + return True + if self._domain == 'script' and not can_cancel: + return True + return False + + def reset_switch(self, *args): + """Reset switch to emulate activate click.""" + _LOGGER.debug('%s: Reset switch to off', self.entity_id) + self.char_on.set_value(0) + def set_state(self, value): """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) + if self.activate_only and value == 0: + _LOGGER.debug('%s: Ignoring turn_off call', self.entity_id) + return self._flag_state = True params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF self.call_service(self._domain, service, params) + if self.activate_only: + call_later(self.hass, 1, self.reset_switch) + def update_state(self, new_state): """Update switch state after state changed.""" + self.activate_only = self.is_activate(new_state) + if self.activate_only: + _LOGGER.debug('%s: Ignore state change, entity is activate only', + self.entity_id) + return + current_state = (new_state.state == STATE_ON) if not self._flag_state: _LOGGER.debug('%s: Set current state to %s', diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 49da6db6125..f78a05b1a45 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -28,7 +28,7 @@ from .const import ( CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, - PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) + PROP_MAX_VALUE, PROP_MIN_STEP, PROP_MIN_VALUE, SERV_THERMOSTAT) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -83,7 +83,8 @@ class Thermostat(HomeAccessory): self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=21.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_target_temperature) # Display units characteristic @@ -97,13 +98,15 @@ class Thermostat(HomeAccessory): self.char_cooling_thresh_temp = serv_thermostat.configure_char( CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_cooling_threshold) if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: self.char_heating_thresh_temp = serv_thermostat.configure_char( CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_heating_threshold) def get_temperature_range(self): @@ -112,11 +115,13 @@ class Thermostat(HomeAccessory): .attributes.get(ATTR_MAX_TEMP) max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ else DEFAULT_MAX_TEMP + max_temp = round(max_temp * 2) / 2 min_temp = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_TEMP) min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ else DEFAULT_MIN_TEMP + min_temp = round(min_temp * 2) / 2 return min_temp, max_temp @@ -140,7 +145,7 @@ class Thermostat(HomeAccessory): @debounce def set_cooling_threshold(self, value): """Set cooling threshold temp to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set cooling threshold temperature to %.2f°C', + _LOGGER.debug('%s: Set cooling threshold temperature to %.1f°C', self.entity_id, value) self._flag_coolingthresh = True low = self.char_heating_thresh_temp.value @@ -156,7 +161,7 @@ class Thermostat(HomeAccessory): @debounce def set_heating_threshold(self, value): """Set heating threshold temp to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', + _LOGGER.debug('%s: Set heating threshold temperature to %.1f°C', self.entity_id, value) self._flag_heatingthresh = True high = self.char_cooling_thresh_temp.value @@ -172,7 +177,7 @@ class Thermostat(HomeAccessory): @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set target temperature to %.2f°C', + _LOGGER.debug('%s: Set target temperature to %.1f°C', self.entity_id, value) self._flag_temperature = True temperature = temperature_to_states(value, self._unit) @@ -301,7 +306,8 @@ class WaterHeater(HomeAccessory): self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=50.0, properties={PROP_MIN_VALUE: min_temp, - PROP_MAX_VALUE: max_temp}, + PROP_MAX_VALUE: max_temp, + PROP_MIN_STEP: 0.5}, setter_callback=self.set_target_temperature) self.char_display_units = serv_thermostat.configure_char( @@ -313,11 +319,13 @@ class WaterHeater(HomeAccessory): .attributes.get(ATTR_MAX_TEMP) max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ else DEFAULT_MAX_TEMP_WATER_HEATER + max_temp = round(max_temp * 2) / 2 min_temp = self.hass.states.get(self.entity_id) \ .attributes.get(ATTR_MIN_TEMP) min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ else DEFAULT_MIN_TEMP_WATER_HEATER + min_temp = round(min_temp * 2) / 2 return min_temp, max_temp @@ -332,7 +340,7 @@ class WaterHeater(HomeAccessory): @debounce def set_target_temperature(self, value): """Set target temperature to value if call came from HomeKit.""" - _LOGGER.debug('%s: Set target temperature to %.2f°C', + _LOGGER.debug('%s: Set target temperature to %.1f°C', self.entity_id, value) self._flag_temperature = True temperature = temperature_to_states(value, self._unit) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 43ae4df3b50..10fdc07e7b4 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -135,12 +135,12 @@ def convert_to_float(state): def temperature_to_homekit(temperature, unit): """Convert temperature to Celsius for HomeKit.""" - return round(temp_util.convert(temperature, unit, TEMP_CELSIUS), 1) + return round(temp_util.convert(temperature, unit, TEMP_CELSIUS) * 2) / 2 def temperature_to_states(temperature, unit): """Convert temperature back from Celsius to Home Assistant unit.""" - return round(temp_util.convert(temperature, TEMP_CELSIUS, unit), 1) + return round(temp_util.convert(temperature, TEMP_CELSIUS, unit) * 2) / 2 def density_to_air_quality(density): diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index ebb4a2db9cb..45b3442ba9f 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -24,6 +24,7 @@ HOMEKIT_DIR = '.homekit' HOMEKIT_ACCESSORY_DISPATCH = { 'lightbulb': 'light', 'outlet': 'switch', + 'switch': 'switch', 'thermostat': 'climate', } diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 4343bcfbc08..d5336217221 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.51'] +REQUIREMENTS = ['pyhomematic==0.1.52'] _LOGGER = logging.getLogger(__name__) @@ -64,8 +64,9 @@ HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', - 'IPKeySwitchPowermeter'], - DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], + 'IPKeySwitchPowermeter', 'IPGarage'], + DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer', 'IPDimmer', + 'ColorEffectLight'], DISCOVER_SENSORS: [ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor', @@ -76,7 +77,7 @@ HM_DEVICE_TYPES = { 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic', 'IPBrightnessSensor'], + 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json index 3f16c45382b..185dbd338f9 100644 --- a/homeassistant/components/homematicip_cloud/.translations/es.json +++ b/homeassistant/components/homematicip_cloud/.translations/es.json @@ -1,19 +1,30 @@ { "config": { "abort": { + "already_configured": "El punto de acceso ya est\u00e1 configurado", + "connection_aborted": "No se pudo conectar al servidor HMIP", "unknown": "Se ha producido un error desconocido." }, "error": { "invalid_pin": "PIN no v\u00e1lido, por favor int\u00e9ntalo de nuevo.", - "press_the_button": "Por favor, pulsa el bot\u00f3n azul" + "press_the_button": "Por favor, pulsa el bot\u00f3n azul", + "register_failed": "No se pudo registrar, por favor intentelo de nuevo.", + "timeout_button": "Tiempo de espera agotado desde que se apret\u00f3 el bot\u00f3n azul, por favor, int\u00e9ntalo de nuevo." }, "step": { "init": { "data": { + "hapid": "ID de punto de acceso (SGTIN)", "name": "Nombre (opcional, utilizado como prefijo para todos los dispositivos)", "pin": "C\u00f3digo PIN (opcional)" - } + }, + "title": "Elegir punto de acceso HomematicIP" + }, + "link": { + "description": "Presione el bot\u00f3n azul en el punto de acceso y el bot\u00f3n enviar para registrar HomematicIP con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n punto de acceso](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlazar punto de acceso" } - } + }, + "title": "HomematicIP Cloud" } } \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json index 46ef55c9eca..b60da944f64 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ko.json +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -21,7 +21,7 @@ "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" }, "link": { - "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" } }, diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index b3b2587fc45..30d4ed0ab8d 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,6 +14,7 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant import exceptions from homeassistant.helpers.json import JSONEncoder from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -107,10 +108,13 @@ def request_handler_factory(view, handler): _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) - result = handler(request, **request.match_info) + try: + result = handler(request, **request.match_info) - if asyncio.iscoroutine(result): - result = await result + if asyncio.iscoroutine(result): + result = await result + except exceptions.Unauthorized: + raise HTTPUnauthorized() if isinstance(result, web.StreamResponse): # The method handler returned a ready-made Response, how nice of it diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json index d58469af044..56e7ed62e9d 100644 --- a/homeassistant/components/hue/.translations/es.json +++ b/homeassistant/components/hue/.translations/es.json @@ -1,11 +1,29 @@ { "config": { "abort": { + "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados", + "already_configured": "El puente ya esta configurado", + "cannot_connect": "No se puede conectar al puente", + "discover_timeout": "No se han descubierto puentes Philips Hue", + "no_bridges": "No se han descubierto puentes Philips Hue.", "unknown": "Se produjo un error desconocido" }, "error": { "linking": "Se produjo un error de enlace desconocido.", "register_failed": "No se pudo registrar, intente de nuevo" - } + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Elige el puente de Hue" + }, + "link": { + "description": "Presione el bot\u00f3n en el puente para registrar Philips Hue con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n en el puente] (/static/images/config_philips_hue.jpg)", + "title": "Link Hub" + } + }, + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json index a9f2a732127..72b2fd6445b 100644 --- a/homeassistant/components/hue/.translations/it.json +++ b/homeassistant/components/hue/.translations/it.json @@ -17,7 +17,7 @@ "data": { "host": "Host" }, - "title": "Selezione il bridge Hue" + "title": "Seleziona il bridge Hue" }, "link": { "description": "Premi il pulsante sul bridge per registrare Philips Hue con Home Assistant\n\n![Posizione del pulsante sul bridge](/static/images/config_philips_hue.jpg)", diff --git a/homeassistant/components/ifttt/.translations/cs.json b/homeassistant/components/ifttt/.translations/cs.json index abbbd9ff890..091ea9bc352 100644 --- a/homeassistant/components/ifttt/.translations/cs.json +++ b/homeassistant/components/ifttt/.translations/cs.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy IFTTT.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset pou\u017e\u00edt akci \"Vytvo\u0159it webovou \u017e\u00e1dost\" z [IFTTT Webhook appletu]({applet_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, "step": { "user": { "description": "Opravdu chcete nastavit IFTTT?", diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json index 13240ccefb1..fc804bba46c 100644 --- a/homeassistant/components/ifttt/.translations/es.json +++ b/homeassistant/components/ifttt/.translations/es.json @@ -1,5 +1,12 @@ { "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes IFTTT.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 usar la acci\u00f3n \"Make a web request\" del [applet IFTTT Webhook] ( {applet_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json\n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, "step": { "user": { "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?", diff --git a/homeassistant/components/ifttt/.translations/it.json b/homeassistant/components/ifttt/.translations/it.json new file mode 100644 index 00000000000..ac81f073347 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Home Assistant deve essere accessibile da internet per ricevere messaggi IFTTT", + "one_instance_allowed": "E' necessaria una sola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai utilizzare l'azione \"Esegui una richiesta web\" dall'applet [Weblet di IFTTT] ( {applet_url} ). \n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Metodo: POST \n - Tipo di contenuto: application / json \n\n Vedi [la documentazione] ( {docs_url} ) su come configurare le automazioni per gestire i dati in arrivo." + }, + "step": { + "user": { + "description": "Sei sicuro di voler impostare IFTTT?", + "title": "Configura l'applet WebHook IFTTT" + } + }, + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 85ee6b9fa1c..209bbcef607 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -88,7 +88,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'IFTTT', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index e44ae6e1ae3..7694dbd6735 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.3'] +REQUIREMENTS = ['numpy==1.15.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 2d06dbbcf34..8f5b599bb88 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -1,5 +1,5 @@ """ -Component that performs TensorFlow classification on images. +Support for performing TensorFlow classification on images. For a quick start, pick a pre-trained COCO model from: https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/detection_model_zoo.md @@ -8,8 +8,8 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/image_processing.tensorflow/ """ import logging -import sys import os +import sys import voluptuous as vol @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.3', 'pillow==5.2.0', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.2.0', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) @@ -28,29 +28,29 @@ ATTR_MATCHES = 'matches' ATTR_SUMMARY = 'summary' ATTR_TOTAL_MATCHES = 'total_matches' -CONF_FILE_OUT = 'file_out' -CONF_MODEL = 'model' -CONF_GRAPH = 'graph' -CONF_LABELS = 'labels' -CONF_MODEL_DIR = 'model_dir' +CONF_AREA = 'area' +CONF_BOTTOM = 'bottom' CONF_CATEGORIES = 'categories' CONF_CATEGORY = 'category' -CONF_AREA = 'area' -CONF_TOP = 'top' +CONF_FILE_OUT = 'file_out' +CONF_GRAPH = 'graph' +CONF_LABELS = 'labels' CONF_LEFT = 'left' -CONF_BOTTOM = 'bottom' +CONF_MODEL = 'model' +CONF_MODEL_DIR = 'model_dir' CONF_RIGHT = 'right' +CONF_TOP = 'top' AREA_SCHEMA = vol.Schema({ - vol.Optional(CONF_TOP, default=0): cv.small_float, - vol.Optional(CONF_LEFT, default=0): cv.small_float, vol.Optional(CONF_BOTTOM, default=1): cv.small_float, - vol.Optional(CONF_RIGHT, default=1): cv.small_float + vol.Optional(CONF_LEFT, default=0): cv.small_float, + vol.Optional(CONF_RIGHT, default=1): cv.small_float, + vol.Optional(CONF_TOP, default=0): cv.small_float, }) CATEGORY_SCHEMA = vol.Schema({ vol.Required(CONF_CATEGORY): cv.string, - vol.Optional(CONF_AREA): AREA_SCHEMA + vol.Optional(CONF_AREA): AREA_SCHEMA, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -58,14 +58,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.All(cv.ensure_list, [cv.template]), vol.Required(CONF_MODEL): vol.Schema({ vol.Required(CONF_GRAPH): cv.isfile, - vol.Optional(CONF_LABELS): cv.isfile, - vol.Optional(CONF_MODEL_DIR): cv.isdir, vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_CATEGORIES, default=[]): - vol.All(cv.ensure_list, [vol.Any( - cv.string, - CATEGORY_SCHEMA - )]) + vol.All(cv.ensure_list, [vol.Any(cv.string, CATEGORY_SCHEMA)]), + vol.Optional(CONF_LABELS): cv.isfile, + vol.Optional(CONF_MODEL_DIR): cv.isdir, }) }) @@ -93,7 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Make sure locations exist if not os.path.isdir(model_dir) or not os.path.exists(labels): - _LOGGER.error("Unable to locate tensorflow models or label map.") + _LOGGER.error("Unable to locate tensorflow models or label map") return # append custom model path to sys.path @@ -118,9 +115,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # pylint: disable=unused-import,unused-variable import cv2 # noqa except ImportError: - _LOGGER.warning("No OpenCV library found. " - "TensorFlow will process image with " - "PIL at reduced resolution.") + _LOGGER.warning( + "No OpenCV library found. TensorFlow will process image with " + "PIL at reduced resolution") # setup tensorflow graph, session, and label map to pass to processor # pylint: disable=no-member @@ -241,23 +238,23 @@ class TensorFlowImageProcessor(ImageProcessingEntity): # Draw custom global region/area if self._area != [0, 0, 1, 1]: draw_box(draw, self._area, - img_width, img_height, - "Detection Area", (0, 255, 255)) + img_width, img_height, "Detection Area", (0, 255, 255)) for category, values in matches.items(): # Draw custom category regions/areas if (category in self._category_areas and self._category_areas[category] != [0, 0, 1, 1]): label = "{} Detection Area".format(category.capitalize()) - draw_box(draw, self._category_areas[category], img_width, - img_height, label, (0, 255, 0)) + draw_box( + draw, self._category_areas[category], img_width, + img_height, label, (0, 255, 0)) # Draw detected objects for instance in values: label = "{0} {1:.1f}%".format(category, instance['score']) - draw_box(draw, instance['box'], - img_width, img_height, - label, (255, 255, 0)) + draw_box( + draw, instance['box'], img_width, img_height, label, + (255, 255, 0)) for path in paths: _LOGGER.info("Saving results image to %s", path) diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 3980503a1ac..14d43cbcaee 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.15.0'] +REQUIREMENTS = ['insteonplm==0.15.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/ios/.translations/es.json b/homeassistant/components/ios/.translations/es.json new file mode 100644 index 00000000000..afd4fedc97e --- /dev/null +++ b/homeassistant/components/ios/.translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Solo se necesita una \u00fanica configuraci\u00f3n de Home Assistant iOS." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar el componente iOS de Home Assistant?", + "title": "Home Assistant iOS" + } + }, + "title": "Home Assistant iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ios/.translations/it.json b/homeassistant/components/ios/.translations/it.json new file mode 100644 index 00000000000..3f587b7ee64 --- /dev/null +++ b/homeassistant/components/ios/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Home Assistant per iOS" + } +} \ No newline at end of file diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 1ca6c00b23a..52df3d47ca1 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN DOMAIN = 'lifx' -REQUIREMENTS = ['aiolifx==0.6.5'] +REQUIREMENTS = ['aiolifx==0.6.6'] CONF_SERVER = 'server' CONF_BROADCAST = 'broadcast' @@ -25,6 +25,8 @@ CONFIG_SCHEMA = vol.Schema({ } }, extra=vol.ALLOW_EXTRA) +DATA_LIFX_MANAGER = 'lifx_manager' + async def async_setup(hass, config): """Set up the LIFX component.""" @@ -43,6 +45,16 @@ async def async_setup_entry(hass, entry): """Set up LIFX from a config entry.""" hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, LIGHT_DOMAIN)) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hass.data.pop(DATA_LIFX_MANAGER).cleanup() + + await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN) + return True diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index 731f0e600fb..00fc4f33741 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['antsar-avion==0.9.1'] +REQUIREMENTS = ['avion==0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 61f5ea39603..ae2d241d81f 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -5,7 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ from homeassistant.components.deconz.const import ( - CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, + CONF_ALLOW_DECONZ_GROUPS, DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, COVER_TYPES, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, @@ -28,16 +28,18 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_light(lights): """Add light from deCONZ.""" entities = [] for light in lights: if light.type not in COVER_TYPES + SWITCH_TYPES: - entities.append(DeconzLight(light)) + entities.append(DeconzLight(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_light)) @callback @@ -47,22 +49,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): allow_group = config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True) for group in groups: if group.lights and allow_group: - entities.append(DeconzLight(group)) + entities.append(DeconzLight(group, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_group', async_add_group)) - async_add_light(hass.data[DATA_DECONZ].api.lights.values()) - async_add_group(hass.data[DATA_DECONZ].api.groups.values()) + async_add_light(gateway.api.lights.values()) + async_add_group(gateway.api.groups.values()) class DeconzLight(Light): """Representation of a deCONZ light.""" - def __init__(self, light): + def __init__(self, light, gateway): """Set up light and add update callback to get data from websocket.""" self._light = light + self.gateway = gateway + self.unsub_dispatcher = None self._features = SUPPORT_BRIGHTNESS self._features |= SUPPORT_FLASH @@ -80,11 +84,14 @@ class DeconzLight(Light): async def async_added_to_hass(self): """Subscribe to lights events.""" self._light.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._light.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._light.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect light object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._light.remove_callback(self.async_update_callback) self._light = None @@ -141,7 +148,7 @@ class DeconzLight(Light): @property def available(self): """Return True if light is available.""" - return self._light.reachable + return self.gateway.available and self._light.reachable @property def should_poll(self): @@ -214,7 +221,7 @@ class DeconzLight(Light): self._light.uniqueid.count(':') != 7): return None serial = self._light.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py new file mode 100644 index 00000000000..96069d50335 --- /dev/null +++ b/homeassistant/components/light/fibaro.py @@ -0,0 +1,183 @@ +""" +Support for Fibaro lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.fibaro/ +""" + +import logging +import asyncio +from functools import partial + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light) +import homeassistant.util.color as color_util +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['fibaro'] + + +def scaleto255(value): + """Scale the input value from 0-100 to 0-255.""" + # Fibaro has a funny way of storing brightness either 0-100 or 0-99 + # depending on device type (e.g. dimmer vs led) + if value > 98: + value = 100 + return max(0, min(255, ((value * 256.0) / 100.0))) + + +def scaleto100(value): + """Scale the input value from 0-255 to 0-100.""" + # Make sure a low but non-zero value is not rounded down to zero + if 0 < value < 3: + return 1 + return max(0, min(100, ((value * 100.4) / 255.0))) + + +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): + """Perform the setup for Fibaro controller devices.""" + if discovery_info is None: + return + + async_add_entities( + [FibaroLight(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['light']], True) + + +class FibaroLight(FibaroDevice, Light): + """Representation of a Fibaro Light, including dimmable.""" + + def __init__(self, fibaro_device, controller): + """Initialize the light.""" + self._supported_flags = 0 + self._last_brightness = 0 + self._color = (0, 0) + self._brightness = None + self._white = 0 + + self._update_lock = asyncio.Lock() + if 'levelChange' in fibaro_device.interfaces: + self._supported_flags |= SUPPORT_BRIGHTNESS + if 'color' in fibaro_device.properties: + self._supported_flags |= SUPPORT_COLOR + if 'setW' in fibaro_device.actions: + self._supported_flags |= SUPPORT_WHITE_VALUE + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + @property + def brightness(self): + """Return the brightness of the light.""" + return scaleto255(self._brightness) + + @property + def hs_color(self): + """Return the color of the light.""" + return self._color + + @property + def white_value(self): + """Return the white value of this light between 0..255.""" + return self._white + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_flags + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_on, **kwargs)) + + def _turn_on(self, **kwargs): + """Really turn the light on.""" + if self._supported_flags & SUPPORT_BRIGHTNESS: + target_brightness = kwargs.get(ATTR_BRIGHTNESS) + + # No brightness specified, so we either restore it to + # last brightness or switch it on at maximum level + if target_brightness is None: + if self._brightness == 0: + if self._last_brightness: + self._brightness = self._last_brightness + else: + self._brightness = 100 + else: + # We set it to the target brightness and turn it on + self._brightness = scaleto100(target_brightness) + + if self._supported_flags & SUPPORT_COLOR: + # Update based on parameters + self._white = kwargs.get(ATTR_WHITE_VALUE, self._white) + self._color = kwargs.get(ATTR_HS_COLOR, self._color) + rgb = color_util.color_hs_to_RGB(*self._color) + self.call_set_color( + int(rgb[0] * self._brightness / 99.0 + 0.5), + int(rgb[1] * self._brightness / 99.0 + 0.5), + int(rgb[2] * self._brightness / 99.0 + 0.5), + int(self._white * self._brightness / 99.0 + + 0.5)) + if self.state == 'off': + self.set_level(int(self._brightness)) + return + + if self._supported_flags & SUPPORT_BRIGHTNESS: + self.set_level(int(self._brightness)) + return + + # The simplest case is left for last. No dimming, just switch on + self.call_turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + async with self._update_lock: + await self.hass.async_add_executor_job( + partial(self._turn_off, **kwargs)) + + def _turn_off(self, **kwargs): + """Really turn the light off.""" + # Let's save the last brightness level before we switch it off + if (self._supported_flags & SUPPORT_BRIGHTNESS) and \ + self._brightness and self._brightness > 0: + self._last_brightness = self._brightness + self._brightness = 0 + self.call_turn_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self.current_binary_state + + async def async_update(self): + """Update the state.""" + async with self._update_lock: + await self.hass.async_add_executor_job(self._update) + + def _update(self): + """Really update the state.""" + # Brightness handling + if self._supported_flags & SUPPORT_BRIGHTNESS: + self._brightness = float(self.fibaro_device.properties.value) + # Color handling + if self._supported_flags & SUPPORT_COLOR: + # Fibaro communicates the color as an 'R, G, B, W' string + rgbw_s = self.fibaro_device.properties.color + if rgbw_s == '0,0,0,0' and\ + 'lastColorSet' in self.fibaro_device.properties: + rgbw_s = self.fibaro_device.properties.lastColorSet + rgbw_list = [int(i) for i in rgbw_s.split(",")][:4] + if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]: + self._color = color_util.color_RGB_to_hs(*rgbw_list[:3]) + if (self._supported_flags & SUPPORT_WHITE_VALUE) and \ + self.brightness != 0: + self._white = min(255, max(0, rgbw_list[3]*100.0 / + self._brightness)) diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py index 9a7baa713a3..de11c96f8b7 100644 --- a/homeassistant/components/light/homematic.py +++ b/homeassistant/components/light/homematic.py @@ -8,8 +8,8 @@ import logging from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.const import STATE_UNKNOWN + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR, + ATTR_EFFECT, SUPPORT_EFFECT, Light) _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ class HMLight(HMDevice, Light): def brightness(self): """Return the brightness of this light between 0..255.""" # Is dimmer? - if self._state == "LEVEL": + if self._state == 'LEVEL': return int(self._hm_get_state() * 255) return None @@ -53,16 +53,47 @@ class HMLight(HMDevice, Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_HOMEMATIC + if 'COLOR' in self._hmdevice.WRITENODE: + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_EFFECT + return SUPPORT_BRIGHTNESS + + @property + def hs_color(self): + """Return the hue and saturation color value [float, float].""" + if not self.supported_features & SUPPORT_COLOR: + return None + hue, sat = self._hmdevice.get_hs_color() + return hue*360.0, sat*100.0 + + @property + def effect_list(self): + """Return the list of supported effects.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect_list() + + @property + def effect(self): + """Return the current color change program of the light.""" + if not self.supported_features & SUPPORT_EFFECT: + return None + return self._hmdevice.get_effect() def turn_on(self, **kwargs): - """Turn the light on.""" + """Turn the light on and/or change color or color effect settings.""" if ATTR_BRIGHTNESS in kwargs and self._state == "LEVEL": percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255 self._hmdevice.set_level(percent_bright, self._channel) - else: + elif ATTR_HS_COLOR not in kwargs and ATTR_EFFECT not in kwargs: self._hmdevice.on(self._channel) + if ATTR_HS_COLOR in kwargs: + self._hmdevice.set_hs_color( + hue=kwargs[ATTR_HS_COLOR][0]/360.0, + saturation=kwargs[ATTR_HS_COLOR][1]/100.0) + if ATTR_EFFECT in kwargs: + self._hmdevice.set_effect(kwargs[ATTR_EFFECT]) + def turn_off(self, **kwargs): """Turn the light off.""" self._hmdevice.off(self._channel) @@ -71,4 +102,7 @@ class HMLight(HMDevice, Light): """Generate a data dict (self._data) from the Homematic metadata.""" # Use LEVEL self._state = "LEVEL" - self._data.update({self._state: STATE_UNKNOWN}) + self._data[self._state] = None + + if self.supported_features & SUPPORT_COLOR: + self._data.update({"COLOR": None, "PROGRAM": None}) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index f346f88c42b..8951b2876a2 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -22,7 +22,7 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light, preprocess_turn_on_alternatives) from homeassistant.components.lifx import ( - DOMAIN as LIFX_DOMAIN, CONF_SERVER, CONF_BROADCAST) + DOMAIN as LIFX_DOMAIN, DATA_LIFX_MANAGER, CONF_SERVER, CONF_BROADCAST) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -155,27 +155,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): interfaces = [{}] lifx_manager = LIFXManager(hass, async_add_entities) + hass.data[DATA_LIFX_MANAGER] = lifx_manager for interface in interfaces: - kwargs = {'discovery_interval': DISCOVERY_INTERVAL} - broadcast_ip = interface.get(CONF_BROADCAST) - if broadcast_ip: - kwargs['broadcast_ip'] = broadcast_ip - lifx_discovery = aiolifx().LifxDiscovery( - hass.loop, lifx_manager, **kwargs) - - kwargs = {} - listen_ip = interface.get(CONF_SERVER) - if listen_ip: - kwargs['listen_ip'] = listen_ip - lifx_discovery.start(**kwargs) - - @callback - def cleanup(event): - """Clean up resources.""" - lifx_discovery.cleanup() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + lifx_manager.start_discovery(interface) return True @@ -226,10 +209,43 @@ class LIFXManager: self.hass = hass self.async_add_entities = async_add_entities self.effects_conductor = aiolifx_effects().Conductor(loop=hass.loop) + self.discoveries = [] + self.cleanup_unsub = self.hass.bus.async_listen( + EVENT_HOMEASSISTANT_STOP, + self.cleanup) self.register_set_state() self.register_effects() + def start_discovery(self, interface): + """Start discovery on a network interface.""" + kwargs = {'discovery_interval': DISCOVERY_INTERVAL} + broadcast_ip = interface.get(CONF_BROADCAST) + if broadcast_ip: + kwargs['broadcast_ip'] = broadcast_ip + lifx_discovery = aiolifx().LifxDiscovery( + self.hass.loop, self, **kwargs) + + kwargs = {} + listen_ip = interface.get(CONF_SERVER) + if listen_ip: + kwargs['listen_ip'] = listen_ip + lifx_discovery.start(**kwargs) + + self.discoveries.append(lifx_discovery) + + @callback + def cleanup(self, event=None): + """Release resources.""" + self.cleanup_unsub() + + for discovery in self.discoveries: + discovery.cleanup() + + for service in [SERVICE_LIFX_SET_STATE, SERVICE_EFFECT_STOP, + SERVICE_EFFECT_PULSE, SERVICE_EFFECT_COLORLOOP]: + self.hass.services.async_remove(DOMAIN, service) + def register_set_state(self): """Register the LIFX set_state service call.""" async def service_handler(service): diff --git a/homeassistant/components/light/niko_home_control.py b/homeassistant/components/light/niko_home_control.py new file mode 100644 index 00000000000..3146954ed62 --- /dev/null +++ b/homeassistant/components/light/niko_home_control.py @@ -0,0 +1,89 @@ +""" +Support for Niko Home Control. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/light.niko_home_control/ +""" +import logging +import socket + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, PLATFORM_SCHEMA, Light) +from homeassistant.const import CONF_HOST +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['niko-home-control==0.1.8'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Niko Home Control light platform.""" + import nikohomecontrol + + host = config.get(CONF_HOST) + + try: + hub = nikohomecontrol.Hub({ + 'ip': host, + 'port': 8000, + 'timeout': 20000, + 'events': True + }) + except socket.error as err: + _LOGGER.error("Unable to access %s (%s)", host, err) + raise PlatformNotReady + + add_devices( + [NikoHomeControlLight(light, hub) for light in hub.list_actions()], + True) + + +class NikoHomeControlLight(Light): + """Representation of an Niko Light.""" + + def __init__(self, light, nhc): + """Set up the Niko Home Control light platform.""" + self._nhc = nhc + self._light = light + self._name = light.name + self._state = None + self._brightness = None + + @property + def name(self): + """Return the display name of this light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + self._light.brightness = kwargs.get(ATTR_BRIGHTNESS, 255) + self._light.turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.turn_off() + self._state = False + + def update(self): + """Fetch new state data for this light.""" + self._light.update() + self._state = self._light.is_on diff --git a/homeassistant/components/light/switch.py b/homeassistant/components/light/switch.py new file mode 100644 index 00000000000..de6247a2772 --- /dev/null +++ b/homeassistant/components/light/switch.py @@ -0,0 +1,113 @@ +""" +Light support for switch entities. + +For more information about this platform, please refer to the documentation at +https://home-assistant.io/components/light.switch/ +""" +import logging +import voluptuous as vol + +from homeassistant.core import State, callback +from homeassistant.components.light import ( + Light, PLATFORM_SCHEMA) +from homeassistant.components import switch +from homeassistant.const import ( + STATE_ON, + ATTR_ENTITY_ID, + CONF_NAME, + CONF_ENTITY_ID, + STATE_UNAVAILABLE +) +from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.event import async_track_state_change +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'Light Switch' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_ENTITY_ID): cv.entity_domain(switch.DOMAIN) +}) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, + discovery_info=None) -> None: + """Initialize Light Switch platform.""" + async_add_entities([LightSwitch(config.get(CONF_NAME), + config[CONF_ENTITY_ID])], True) + + +class LightSwitch(Light): + """Represents a Switch as a Light.""" + + def __init__(self, name: str, switch_entity_id: str) -> None: + """Initialize Light Switch.""" + self._name = name # type: str + self._switch_entity_id = switch_entity_id # type: str + self._is_on = False # type: bool + self._available = False # type: bool + self._async_unsub_state_changed = None + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def is_on(self) -> bool: + """Return true if light switch is on.""" + return self._is_on + + @property + def available(self) -> bool: + """Return true if light switch is on.""" + return self._available + + @property + def should_poll(self) -> bool: + """No polling needed for a light switch.""" + return False + + async def async_turn_on(self, **kwargs): + """Forward the turn_on command to the switch in this light switch.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call( + switch.DOMAIN, switch.SERVICE_TURN_ON, data, blocking=True) + + async def async_turn_off(self, **kwargs): + """Forward the turn_off command to the switch in this light switch.""" + data = {ATTR_ENTITY_ID: self._switch_entity_id} + await self.hass.services.async_call( + switch.DOMAIN, switch.SERVICE_TURN_OFF, data, blocking=True) + + async def async_update(self): + """Query the switch in this light switch and determine the state.""" + switch_state = self.hass.states.get(self._switch_entity_id) + + if switch_state is None: + self._available = False + return + + self._is_on = switch_state.state == STATE_ON + self._available = switch_state.state != STATE_UNAVAILABLE + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + @callback + def async_state_changed_listener(entity_id: str, old_state: State, + new_state: State): + """Handle child updates.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_state_changed = async_track_state_change( + self.hass, self._switch_entity_id, async_state_changed_listener) + + async def async_will_remove_from_hass(self): + """Handle removal from Home Assistant.""" + if self._async_unsub_state_changed is not None: + self._async_unsub_state_changed() + self._async_unsub_state_changed = None + self._available = False diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index cc88dbfe29f..f2e8e120d53 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -41,7 +41,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'philips.light.bulb', 'philips.light.candle', 'philips.light.candle2', - 'philips.light.mono1']), + 'philips.light.mono1', + 'philips.light.downlight', + ]), }) # The light does not accept cct values < 1 @@ -151,7 +153,8 @@ async def async_setup_platform(hass, config, async_add_entities, hass.data[DATA_KEY][host] = device elif model in ['philips.light.bulb', 'philips.light.candle', - 'philips.light.candle2']: + 'philips.light.candle2', + 'philips.light.downlight']: from miio import PhilipsBulb light = PhilipsBulb(host, token) device = XiaomiPhilipsBulb(name, light, model, unique_id) @@ -263,7 +266,7 @@ class XiaomiPhilipsAbstractLight(Light): """Call a light command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from light: %s", result) @@ -303,7 +306,7 @@ class XiaomiPhilipsAbstractLight(Light): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -331,7 +334,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -481,7 +484,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -541,7 +544,7 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -587,7 +590,7 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -715,7 +718,7 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): """Fetch state from the device.""" from miio import DeviceException try: - state = await self.hass.async_add_job(self._light.status) + state = await self.hass.async_add_executor_job(self._light.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index e9904f0163a..22923602dc2 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -58,7 +58,7 @@ def is_locked(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for locks.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LOCKS) await component.async_setup(config) @@ -79,6 +79,11 @@ async def async_setup(hass, config): return True +async def async_setup_entry(hass, entry): + """Set up a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + class LockDevice(Entity): """Representation of a lock.""" diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index ee43eb942c4..b62382e6dd1 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -16,8 +16,11 @@ from homeassistant.components.mqtt import ( CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) -from homeassistant.components import mqtt +from homeassistant.components import mqtt, lock +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType, ConfigType _LOGGER = logging.getLogger(__name__) @@ -40,20 +43,32 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): - """Set up the MQTT lock.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info=None): + """Set up MQTT lock panel through configuration.yaml.""" + await _async_setup_entity(hass, config, async_add_entities) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up MQTT lock dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add an MQTT lock.""" + config = PLATFORM_SCHEMA(discovery_payload) + await _async_setup_entity(hass, config, async_add_entities, + discovery_payload[ATTR_DISCOVERY_HASH]) + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(lock.DOMAIN, 'mqtt'), + async_discover) + + +async def _async_setup_entity(hass, config, async_add_entities, + discovery_hash=None): + """Set up the MQTT Lock platform.""" value_template = config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - discovery_hash = None - if discovery_info is not None and ATTR_DISCOVERY_HASH in discovery_info: - discovery_hash = discovery_info[ATTR_DISCOVERY_HASH] - async_add_entities([MqttLock( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 4fd9faafcbe..e7eaea8fcd3 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME) -REQUIREMENTS = ['pynello==1.5.1'] +REQUIREMENTS = ['pynello==2.0.2'] _LOGGER = logging.getLogger(__name__) @@ -29,7 +29,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Nello lock platform.""" - from pynello import Nello + from pynello.private import Nello nello = Nello(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)) add_entities([NelloLock(lock) for lock in nello.locations], True) diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 4a2b71f0b48..796c62377f1 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -8,8 +8,10 @@ import logging 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 _LOGGER = logging.getLogger(__name__) @@ -28,9 +30,22 @@ POLYCONTROL = 0x10E DANALOCK_V2_BTZE = 0x2 POLYCONTROL_DANALOCK_V2_BTZE_LOCK = (POLYCONTROL, DANALOCK_V2_BTZE) WORKAROUND_V2BTZE = 'v2btze' +WORKAROUND_DEVICE_STATE = 'state' DEVICE_MAPPINGS = { - POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE + POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE, + # Kwikset 914TRL ZW500 + (0x0090, 0x440): WORKAROUND_DEVICE_STATE, + # Yale YRD210 + (0x0129, 0x0209): WORKAROUND_DEVICE_STATE, + (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, + (0x0129, 0x0000): WORKAROUND_DEVICE_STATE, + # Yale YRD220 (as reported by adrum in PR #17386) + (0x0109, 0x0000): WORKAROUND_DEVICE_STATE, + # Schlage BE469 + (0x003B, 0x5044): WORKAROUND_DEVICE_STATE, + # Schlage FE599NX + (0x003B, 0x504C): WORKAROUND_DEVICE_STATE, } LOCK_NOTIFICATION = { @@ -120,9 +135,18 @@ CLEAR_USERCODE_SCHEMA = vol.Schema({ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Z-Wave Lock platform.""" - await zwave.async_setup_platform( - hass, config, async_add_entities, discovery_info) + """Old method of setting up Z-Wave locks.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Z-Wave Lock from Config Entry.""" + @callback + def async_add_lock(lock): + """Add Z-Wave Lock.""" + async_add_entities([lock]) + + async_dispatcher_connect(hass, 'zwave_new_lock', async_add_lock) network = hass.data[zwave.const.DATA_NETWORK] @@ -204,6 +228,7 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._notification = None self._lock_status = None self._v2btze = None + self._state_workaround = False # Enable appropriate workaround flags for our device # Make sure that we have values for the key before converting to int @@ -216,6 +241,11 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): self._v2btze = 1 _LOGGER.debug("Polycontrol Danalock v2 BTZE " "workaround enabled") + if DEVICE_MAPPINGS[specific_sensor_key] == \ + WORKAROUND_DEVICE_STATE: + self._state_workaround = True + _LOGGER.debug( + "Notification device state workaround enabled") self.update_properties() def update_properties(self): @@ -225,7 +255,8 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if self.values.access_control: notification_data = self.values.access_control.data self._notification = LOCK_NOTIFICATION.get(str(notification_data)) - + if self._state_workaround: + self._state = LOCK_STATUS.get(str(notification_data)) if self._v2btze: if self.values.v2btze_advanced and \ self.values.v2btze_advanced.data == CONFIG_ADVANCED: diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 5bd7ed0d2f5..c7a37411f1e 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -317,43 +317,90 @@ def humanify(hass, events): } +def _get_related_entity_ids(session, entity_filter): + from homeassistant.components.recorder.models import States + from homeassistant.components.recorder.util import \ + RETRIES, QUERY_RETRY_WAIT + from sqlalchemy.exc import SQLAlchemyError + import time + + timer_start = time.perf_counter() + + query = session.query(States).with_entities(States.entity_id).distinct() + + for tryno in range(0, RETRIES): + try: + result = [ + row.entity_id for row in query + if entity_filter(row.entity_id)] + + if _LOGGER.isEnabledFor(logging.DEBUG): + elapsed = time.perf_counter() - timer_start + _LOGGER.debug( + 'fetching %d distinct domain/entity_id pairs took %fs', + len(result), + elapsed) + + return result + except SQLAlchemyError as err: + _LOGGER.error("Error executing query: %s", err) + + if tryno == RETRIES - 1: + raise + else: + time.sleep(QUERY_RETRY_WAIT) + + +def _generate_filter_from_config(config): + from homeassistant.helpers.entityfilter import generate_filter + + excluded_entities = [] + excluded_domains = [] + included_entities = [] + included_domains = [] + + exclude = config.get(CONF_EXCLUDE) + if exclude: + excluded_entities = exclude.get(CONF_ENTITIES, []) + excluded_domains = exclude.get(CONF_DOMAINS, []) + include = config.get(CONF_INCLUDE) + if include: + included_entities = include.get(CONF_ENTITIES, []) + included_domains = include.get(CONF_DOMAINS, []) + + return generate_filter(included_domains, included_entities, + excluded_domains, excluded_entities) + + 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) + entities_filter = _generate_filter_from_config(config) + with session_scope(hass=hass) as session: + if entity_id is not None: + entity_ids = [entity_id.lower()] + else: + entity_ids = _get_related_entity_ids(session, entities_filter) + query = session.query(Events).order_by(Events.time_fired) \ - .outerjoin(States, (Events.event_id == States.event_id)) \ + .outerjoin(States, (Events.event_id == States.event_id)) \ .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \ .filter((Events.time_fired > start_day) & (Events.time_fired < end_day)) \ - .filter((States.last_updated == States.last_changed) + .filter(((States.last_updated == States.last_changed) & + States.entity_id.in_(entity_ids)) | (States.state_id.is_(None))) - if entity_id is not None: - query = query.filter(States.entity_id == entity_id.lower()) - events = execute(query) - return humanify(hass, _exclude_events(events, config)) + + return humanify(hass, _exclude_events(events, entities_filter)) -def _exclude_events(events, config): - """Get list of filtered events.""" - excluded_entities = [] - excluded_domains = [] - included_entities = [] - included_domains = [] - exclude = config.get(CONF_EXCLUDE) - if exclude: - excluded_entities = exclude[CONF_ENTITIES] - excluded_domains = exclude[CONF_DOMAINS] - include = config.get(CONF_INCLUDE) - if include: - included_entities = include[CONF_ENTITIES] - included_domains = include[CONF_DOMAINS] - +def _exclude_events(events, entities_filter): filtered_events = [] for event in events: domain, entity_id = None, None @@ -398,34 +445,12 @@ def _exclude_events(events, config): domain = event.data.get(ATTR_DOMAIN) entity_id = event.data.get(ATTR_ENTITY_ID) - if domain or entity_id: - # filter if only excluded is configured for this domain - if excluded_domains and domain in excluded_domains and \ - not included_domains: - if (included_entities and entity_id not in included_entities) \ - or not included_entities: - continue - # filter if only included is configured for this domain - elif not excluded_domains and included_domains and \ - domain not in included_domains: - if (included_entities and entity_id not in included_entities) \ - or not included_entities: - continue - # filter if included and excluded is configured for this domain - elif excluded_domains and included_domains and \ - (domain not in included_domains or - domain in excluded_domains): - if (included_entities and entity_id not in included_entities) \ - or not included_entities or domain in excluded_domains: - continue - # filter if only included is configured for this entity - elif not excluded_domains and not included_domains and \ - included_entities and entity_id not in included_entities: - continue - # check if logbook entry is excluded for this entity - if entity_id in excluded_entities: - continue - filtered_events.append(event) + 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 diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index a8cde6a2b93..5234dbaf29d 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -6,7 +6,9 @@ at https://www.home-assistant.io/lovelace/ """ from functools import wraps import logging +import os from typing import Dict, List, Union +import time import uuid import voluptuous as vol @@ -18,6 +20,7 @@ import homeassistant.util.ruamel_yaml as yaml _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' +LOVELACE_DATA = 'lovelace' LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name @@ -60,7 +63,7 @@ SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_CARD, vol.Required('card_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) @@ -68,7 +71,7 @@ SCHEMA_UPDATE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_ADD_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_CARD, vol.Required('view_id'): str, - vol.Required('card_config'): vol.Any(str, Dict), + vol.Required('card_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -96,14 +99,14 @@ SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ SCHEMA_UPDATE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_UPDATE_VIEW, vol.Required('view_id'): str, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), }) SCHEMA_ADD_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_ADD_VIEW, - vol.Required('view_config'): vol.Any(str, Dict), + vol.Required('view_config'): vol.Any(str, dict), vol.Optional('position'): int, vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, FORMAT_YAML), @@ -133,9 +136,37 @@ class DuplicateIdError(HomeAssistantError): """Duplicate ID's.""" -def load_config(fname: str) -> JSON_TYPE: +def load_config(hass) -> JSON_TYPE: """Load a YAML file.""" - return yaml.load_yaml(fname, False) + fname = hass.config.path(LOVELACE_CONFIG_FILE) + + # Check for a cached version of the config + if LOVELACE_DATA in hass.data: + config, last_update = hass.data[LOVELACE_DATA] + modtime = os.path.getmtime(fname) + if config and last_update > modtime: + return config + + config = yaml.load_yaml(fname, False) + seen_card_ids = set() + seen_view_ids = set() + for view in config.get('views', []): + view_id = str(view.get('id', '')) + if view_id: + if view_id in seen_view_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in views'.format(view_id)) + seen_view_ids.add(view_id) + for card in view.get('cards', []): + card_id = str(card.get('id', '')) + if card_id: + if card_id in seen_card_ids: + raise DuplicateIdError( + 'ID `{}` has multiple occurances in cards' + .format(card_id)) + seen_card_ids.add(card_id) + hass.data[LOVELACE_DATA] = (config, time.time()) + return config def migrate_config(fname: str) -> None: @@ -301,35 +332,39 @@ def get_view(fname: str, view_id: str, data_format: str = FORMAT_YAML) -> None: """Get view without it's cards.""" round_trip = data_format == FORMAT_YAML config = yaml.load_yaml(fname, round_trip) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - del view['cards'] - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(view) - return view + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + del found['cards'] + if data_format == FORMAT_YAML: + return yaml.object_to_yaml(found) + return found def update_view(fname: str, view_id: str, view_config, data_format: str = FORMAT_YAML) -> None: """Update view.""" config = yaml.load_yaml(fname, True) + found = None for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - view_config['cards'] = view.get('cards', []) - view.clear() - view.update(view_config) - yaml.save_yaml(fname, config) - return - - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) + if data_format == FORMAT_YAML: + view_config = yaml.yaml_to_object(view_config) + view_config['cards'] = found.get('cards', []) + found.clear() + found.update(view_config) + yaml.save_yaml(fname, config) def add_view(fname: str, view_config: str, @@ -350,30 +385,34 @@ def move_view(fname: str, view_id: str, position: int) -> None: """Move a view to a different position.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.insert(position, views.pop(views.index(view))) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.insert(position, views.pop(views.index(found))) + yaml.save_yaml(fname, config) def delete_view(fname: str, view_id: str) -> None: """Delete a view.""" config = yaml.load_yaml(fname, True) views = config.get('views', []) + found = None for view in views: - if str(view.get('id', '')) != view_id: - continue - views.pop(views.index(view)) - yaml.save_yaml(fname, config) - return + if str(view.get('id', '')) == view_id: + found = view + break + if found is None: + raise ViewNotFoundError( + "View with ID: {} was not found in {}.".format(view_id, fname)) - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) + views.pop(views.index(found)) + yaml.save_yaml(fname, config) async def async_setup(hass, config): @@ -445,6 +484,8 @@ def handle_yaml_errors(func): error = 'unsupported_error', str(err) except yaml.WriteError as err: error = 'write_error', str(err) + except DuplicateIdError as err: + error = 'duplicate_id', str(err) except CardNotFoundError as err: error = 'card_not_found', str(err) except ViewNotFoundError as err: @@ -464,8 +505,7 @@ def handle_yaml_errors(func): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job( - load_config, hass.config.path(LOVELACE_CONFIG_FILE)) + return await hass.async_add_executor_job(load_config, hass) @websocket_api.async_response diff --git a/homeassistant/components/luftdaten/.translations/ca.json b/homeassistant/components/luftdaten/.translations/ca.json new file mode 100644 index 00000000000..1254b41bddf --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "No s'ha pogut comunicar amb l'API de Luftdaten", + "invalid_sensor": "El sensor no est\u00e0 disponible o no \u00e9s v\u00e0lid", + "sensor_exists": "Sensor ja registrat" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostra al mapa", + "station_id": "Identificador del sensor Luftdaten" + }, + "title": "Crear Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/cs.json b/homeassistant/components/luftdaten/.translations/cs.json new file mode 100644 index 00000000000..701ccf2612c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Nelze komunikovat s Luftdaten API", + "invalid_sensor": "Senzor nen\u00ed k dispozici nebo je neplatn\u00fd", + "sensor_exists": "Senzor je ji\u017e zaregistrov\u00e1n" + }, + "step": { + "user": { + "data": { + "show_on_map": "Uka\u017e na map\u011b", + "station_id": "ID senzoru Luftdaten" + }, + "title": "Definujte Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/de.json b/homeassistant/components/luftdaten/.translations/de.json new file mode 100644 index 00000000000..136b907df81 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "communication_error": "Keine Kommunikation mit Lufdaten API m\u00f6glich", + "invalid_sensor": "Sensor nicht verf\u00fcgbar oder ung\u00fcltig", + "sensor_exists": "Sensor bereits registriert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Auf Karte anzeigen" + } + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/en.json b/homeassistant/components/luftdaten/.translations/en.json new file mode 100644 index 00000000000..d6c86e9ac1f --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Unable to communicate with the Luftdaten API", + "invalid_sensor": "Sensor not available or invalid", + "sensor_exists": "Sensor already registered" + }, + "step": { + "user": { + "data": { + "show_on_map": "Show on map", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Define Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/es.json b/homeassistant/components/luftdaten/.translations/es.json new file mode 100644 index 00000000000..e93da557ae8 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "No se puede comunicar con la API de Luftdaten", + "invalid_sensor": "Sensor no disponible o no v\u00e1lido", + "sensor_exists": "Sensor ya registrado" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostrar en el mapa", + "station_id": "Sensro ID de Luftdaten" + }, + "title": "Definir Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ko.json b/homeassistant/components/luftdaten/.translations/ko.json new file mode 100644 index 00000000000..7d182cc1a0e --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Luftdaten API \uc640 \ud1b5\uc2e0 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "invalid_sensor": "\uc13c\uc11c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac70\ub098 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4", + "sensor_exists": "\uc13c\uc11c\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "show_on_map": "\uc9c0\ub3c4\uc5d0 \ud45c\uc2dc\ud558\uae30", + "station_id": "Luftdaten \uc13c\uc11c ID" + }, + "title": "Luftdaten \uc124\uc815" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/lb.json b/homeassistant/components/luftdaten/.translations/lb.json new file mode 100644 index 00000000000..931d2a5557c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kann net mat der Luftdaten API kommuniz\u00e9ieren", + "invalid_sensor": "Sensor net disponibel oder ong\u00eblteg", + "sensor_exists": "Sensor ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Op der Kaart uweisen", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Luftdaten d\u00e9fin\u00e9ieren" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/nl.json b/homeassistant/components/luftdaten/.translations/nl.json new file mode 100644 index 00000000000..3284b581f5f --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kan niet communiceren met de Luftdaten API", + "invalid_sensor": "Sensor niet beschikbaar of ongeldig", + "sensor_exists": "Sensor bestaat al" + }, + "step": { + "user": { + "data": { + "show_on_map": "Toon op kaart", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definieer Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/no.json b/homeassistant/components/luftdaten/.translations/no.json new file mode 100644 index 00000000000..ac15a68bc4b --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Kan ikke kommunisere med Luftdaten API", + "invalid_sensor": "Sensor er ikke tilgjengelig eller ugyldig", + "sensor_exists": "Sensor er allerede registrert" + }, + "step": { + "user": { + "data": { + "show_on_map": "Vis p\u00e5 kart", + "station_id": "Luftdaten Sensor ID" + }, + "title": "Definer Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/pl.json b/homeassistant/components/luftdaten/.translations/pl.json new file mode 100644 index 00000000000..5a2c30db44c --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z API Luftdaten", + "invalid_sensor": "Sensor niedost\u0119pny lub nieprawid\u0142owy", + "sensor_exists": "Sensor zosta\u0142 ju\u017c zarejestrowany" + }, + "step": { + "user": { + "data": { + "show_on_map": "Poka\u017c na mapie", + "station_id": "ID sensora Luftdaten" + }, + "title": "Konfiguracja Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json new file mode 100644 index 00000000000..506a5c05485 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a API Luftdaten", + "invalid_sensor": "\u0414\u0430\u0442\u0447\u0438\u043a \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d", + "sensor_exists": "\u0414\u0430\u0442\u0447\u0438\u043a \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435", + "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/sl.json b/homeassistant/components/luftdaten/.translations/sl.json new file mode 100644 index 00000000000..c1dd0462f94 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "Ne morem komunicirati z Luftdaten API-jem", + "invalid_sensor": "Senzor ni na voljo ali je neveljaven", + "sensor_exists": "Senzor je \u017ee registriran" + }, + "step": { + "user": { + "data": { + "show_on_map": "Prika\u017ei na zemljevidu", + "station_id": "Luftdaten ID Senzorja" + }, + "title": "Dolo\u010dite Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/zh-Hans.json b/homeassistant/components/luftdaten/.translations/zh-Hans.json new file mode 100644 index 00000000000..375a08d8a45 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u65e0\u6cd5\u4e0e Luftdaten API \u901a\u4fe1", + "invalid_sensor": "\u4f20\u611f\u5668\u4e0d\u53ef\u7528\u6216\u65e0\u6548", + "sensor_exists": "\u4f20\u611f\u5668\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u5728\u5730\u56fe\u4e0a\u663e\u793a", + "station_id": "Luftdaten \u4f20\u611f\u5668 ID" + }, + "title": "\u5b9a\u4e49 Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/zh-Hant.json b/homeassistant/components/luftdaten/.translations/zh-Hant.json new file mode 100644 index 00000000000..5ea3f682631 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "communication_error": "\u7121\u6cd5\u8207 Luftdaten API \u9032\u884c\u901a\u4fe1", + "invalid_sensor": "\u7121\u6cd5\u4f7f\u7528\u6216\u7121\u6548\u7684\u611f\u61c9\u5668", + "sensor_exists": "\u611f\u61c9\u5668\u5df2\u8a3b\u518a" + }, + "step": { + "user": { + "data": { + "show_on_map": "\u65bc\u5730\u5716\u986f\u793a", + "station_id": "Luftdaten \u611f\u61c9\u5668 ID" + }, + "title": "\u5b9a\u7fa9 Luftdaten" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py new file mode 100644 index 00000000000..b00fca7d3c0 --- /dev/null +++ b/homeassistant/components/luftdaten/__init__.py @@ -0,0 +1,170 @@ +""" +Support for Luftdaten stations. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/luftdaten/ +""" +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS, + CONF_SHOW_ON_MAP, TEMP_CELSIUS) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .config_flow import configured_sensors +from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN + +REQUIREMENTS = ['luftdaten==0.3.4'] + +_LOGGER = logging.getLogger(__name__) + +DATA_LUFTDATEN = 'luftdaten' +DATA_LUFTDATEN_CLIENT = 'data_luftdaten_client' +DATA_LUFTDATEN_LISTENER = 'data_luftdaten_listener' +DEFAULT_ATTRIBUTION = "Data provided by luftdaten.info" + +SENSOR_HUMIDITY = 'humidity' +SENSOR_PM10 = 'P1' +SENSOR_PM2_5 = 'P2' +SENSOR_PRESSURE = 'pressure' +SENSOR_TEMPERATURE = 'temperature' + +TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN) + +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +SENSORS = { + SENSOR_TEMPERATURE: ['Temperature', 'mdi:thermometer', TEMP_CELSIUS], + SENSOR_HUMIDITY: ['Humidity', 'mdi:water-percent', '%'], + SENSOR_PRESSURE: ['Pressure', 'mdi:arrow-down-bold', 'Pa'], + SENSOR_PM10: ['PM10', 'mdi:thought-bubble', + VOLUME_MICROGRAMS_PER_CUBIC_METER], + SENSOR_PM2_5: ['PM2.5', 'mdi:thought-bubble-outline', + VOLUME_MICROGRAMS_PER_CUBIC_METER] +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.Schema({ + vol.Required(CONF_SENSOR_ID): cv.positive_int, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Luftdaten component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT] = {} + hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + station_id = conf.get(CONF_SENSOR_ID) + + if station_id not in configured_sensors(hass): + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data={ + CONF_SENSORS: conf[CONF_SENSORS], + CONF_SENSOR_ID: conf[CONF_SENSOR_ID], + CONF_SHOW_ON_MAP: conf[CONF_SHOW_ON_MAP], + } + ) + ) + + hass.data[DOMAIN][CONF_SCAN_INTERVAL] = conf[CONF_SCAN_INTERVAL] + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up Luftdaten as config entry.""" + from luftdaten import Luftdaten + from luftdaten.exceptions import LuftdatenError + + session = async_get_clientsession(hass) + + try: + luftdaten = LuftDatenData( + Luftdaten( + config_entry.data[CONF_SENSOR_ID], hass.loop, session), + config_entry.data.get(CONF_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(SENSORS))) + await luftdaten.async_update() + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][config_entry.entry_id] = \ + luftdaten + except LuftdatenError: + raise ConfigEntryNotReady + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + config_entry, 'sensor')) + + async def refresh_sensors(event_time): + """Refresh Luftdaten data.""" + await luftdaten.async_update() + async_dispatcher_send(hass, TOPIC_UPDATE) + + hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, refresh_sensors, + hass.data[DOMAIN].get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Luftdaten config entry.""" + remove_listener = hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + for component in ('sensor', ): + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT].pop(config_entry.entry_id) + + return True + + +class LuftDatenData: + """Define a generic Luftdaten object.""" + + def __init__(self, client, sensor_conditions): + """Initialize the Luftdata object.""" + self.client = client + self.data = {} + self.sensor_conditions = sensor_conditions + + async def async_update(self): + """Update sensor/binary sensor data.""" + from luftdaten.exceptions import LuftdatenError + + try: + await self.client.get_data() + + self.data[DATA_LUFTDATEN] = self.client.values + self.data[DATA_LUFTDATEN].update(self.client.meta) + + except LuftdatenError: + _LOGGER.error("Unable to retrieve data from luftdaten.info") diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py new file mode 100644 index 00000000000..33715c3c0c1 --- /dev/null +++ b/homeassistant/components/luftdaten/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow to configure the Luftdaten component.""" +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_sensors(hass): + """Return a set of configured Luftdaten sensors.""" + return set( + '{0}'.format(entry.data[CONF_SENSOR_ID]) + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class LuftDatenFlowHandler(config_entries.ConfigFlow): + """Handle a Luftdaten config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @callback + def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = OrderedDict() + data_schema[vol.Required(CONF_SENSOR_ID)] = str + data_schema[vol.Optional(CONF_SHOW_ON_MAP, default=False)] = bool + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(data_schema), + errors=errors or {} + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from luftdaten import Luftdaten, exceptions + + if not user_input: + return self._show_form() + + sensor_id = user_input[CONF_SENSOR_ID] + + if sensor_id in configured_sensors(self.hass): + return self._show_form({CONF_SENSOR_ID: 'sensor_exists'}) + + session = aiohttp_client.async_get_clientsession(self.hass) + luftdaten = Luftdaten( + user_input[CONF_SENSOR_ID], self.hass.loop, session) + try: + await luftdaten.get_data() + valid = await luftdaten.validate_sensor() + except exceptions.LuftdatenConnectionError: + return self._show_form( + {CONF_SENSOR_ID: 'communication_error'}) + + if not valid: + return self._show_form({CONF_SENSOR_ID: 'invalid_sensor'}) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input.update({CONF_SCAN_INTERVAL: scan_interval.seconds}) + + return self.async_create_entry(title=sensor_id, data=user_input) diff --git a/homeassistant/components/luftdaten/const.py b/homeassistant/components/luftdaten/const.py new file mode 100644 index 00000000000..2f87f857545 --- /dev/null +++ b/homeassistant/components/luftdaten/const.py @@ -0,0 +1,10 @@ +"""Define constants for the Luftdaten component.""" +from datetime import timedelta + +ATTR_SENSOR_ID = 'sensor_id' + +CONF_SENSOR_ID = 'sensor_id' + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +DOMAIN = 'luftdaten' diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json new file mode 100644 index 00000000000..2ba15087c48 --- /dev/null +++ b/homeassistant/components/luftdaten/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "title": "Luftdaten", + "step": { + "user": { + "title": "Define Luftdaten", + "data": { + "station_id": "Luftdaten Sensor ID", + "show_on_map": "Show on map" + + } + } + }, + "error": { + "sensor_exists": "Sensor already registered", + "invalid_sensor": "Sensor not available or invalid", + "communication_error": "Unable to communicate with the Luftdaten API" + } + } +} diff --git a/homeassistant/components/lupusec.py b/homeassistant/components/lupusec.py new file mode 100644 index 00000000000..162b49ef9b2 --- /dev/null +++ b/homeassistant/components/lupusec.py @@ -0,0 +1,94 @@ +""" +This component provides basic support for Lupusec Home Security system. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/lupusec +""" + +import logging + +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, + CONF_NAME, CONF_IP_ADDRESS) +from homeassistant.helpers.entity import Entity +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['lupupy==0.0.10'] + +DOMAIN = 'lupusec' + +NOTIFICATION_ID = 'lupusec_notification' +NOTIFICATION_TITLE = 'Lupusec Security Setup' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + +LUPUSEC_PLATFORMS = [ + 'alarm_control_panel', 'binary_sensor', 'switch' +] + + +def setup(hass, config): + """Set up Lupusec component.""" + from lupupy.exceptions import LupusecException + + conf = config[DOMAIN] + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + ip_address = conf[CONF_IP_ADDRESS] + name = conf.get(CONF_NAME) + + try: + hass.data[DOMAIN] = LupusecSystem(username, password, ip_address, name) + except LupusecException as ex: + _LOGGER.error(ex) + + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for platform in LUPUSEC_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True + + +class LupusecSystem: + """Lupusec System class.""" + + def __init__(self, username, password, ip_address, name): + """Initialize the system.""" + import lupupy + self.lupusec = lupupy.Lupusec(username, password, ip_address) + self.name = name + + +class LupusecDevice(Entity): + """Representation of a Lupusec device.""" + + def __init__(self, data, device): + """Initialize a sensor for Lupusec device.""" + self._data = data + self._device = device + + def update(self): + """Update automation state.""" + self._device.refresh() + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.name diff --git a/homeassistant/components/mailgun/.translations/cs.json b/homeassistant/components/mailgun/.translations/cs.json new file mode 100644 index 00000000000..2f7c4e5a902 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Mailgun.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [Webhooks with Mailgun]({mailgun_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace / json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu Mailgun?", + "title": "Nastavit Mailgun Webhook" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/es.json b/homeassistant/components/mailgun/.translations/es.json new file mode 100644 index 00000000000..4a10ff69b69 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Mailgun] ( {mailgun_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?" + } + }, + "title": "Mailgun" + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/.translations/zh-Hans.json b/homeassistant/components/mailgun/.translations/zh-Hans.json new file mode 100644 index 00000000000..06c1d3624f4 --- /dev/null +++ b/homeassistant/components/mailgun/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Mailgun \u6d88\u606f\u3002", + "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 [Mailgun \u7684 Webhook]({mailgun_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index e78dc0aa479..7fa08bb0f22 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -81,7 +81,7 @@ async def verify_webhook(hass, token=None, timestamp=None, signature=None): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'Mailgun', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index 858f95e7ae4..de60f7eee93 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.10.29'] +REQUIREMENTS = ['youtube_dl==2018.11.07'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 4767428894b..7a1e240d82e 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -9,9 +9,9 @@ import requests import voluptuous as vol from homeassistant.components.media_player import ( - MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, - SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW, PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, @@ -33,8 +33,12 @@ DEFAULT_NAME = "DirecTV Receiver" DEFAULT_PORT = 8080 SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ - SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ - SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY + +SUPPORT_DTV_CLIENT = SUPPORT_PAUSE | \ + SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY DATA_DIRECTV = 'data_directv' @@ -48,12 +52,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DirecTV platform.""" - known_devices = hass.data.get(DATA_DIRECTV) - if not known_devices: - known_devices = [] + known_devices = hass.data.get(DATA_DIRECTV, set()) hosts = [] if CONF_HOST in config: + _LOGGER.debug("Adding configured device %s with client address %s ", + config.get(CONF_NAME), config.get(CONF_DEVICE)) hosts.append([ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT), config.get(CONF_DEVICE) @@ -64,29 +68,52 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = 'DirecTV_{}'.format(discovery_info.get('serial', '')) # Attempt to discover additional RVU units - try: - resp = requests.get( - 'http://%s:%d/info/getLocations' % (host, DEFAULT_PORT)).json() - if "locations" in resp: - for loc in resp["locations"]: - if("locationName" in loc and "clientAddr" in loc - and loc["clientAddr"] not in known_devices): - hosts.append([str.title(loc["locationName"]), host, - DEFAULT_PORT, loc["clientAddr"]]) + _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) - except requests.exceptions.RequestException: + from DirectPy import DIRECTV + dtv = DIRECTV(host, DEFAULT_PORT) + try: + resp = dtv.get_locations() + except requests.exceptions.RequestException as ex: # Bail out and just go forward with uPnP data - if DEFAULT_DEVICE not in known_devices: - hosts.append([name, host, DEFAULT_PORT, DEFAULT_DEVICE]) + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + _LOGGER.debug("Request exception %s trying to get locations", ex) + resp = { + 'locations': [{ + 'locationName': name, + 'clientAddr': DEFAULT_DEVICE + }] + } + + _LOGGER.debug("Known devices: %s", known_devices) + for loc in resp.get("locations") or []: + if "locationName" not in loc or "clientAddr" not in loc: + continue + + # Make sure that this device is not already configured + # Comparing based on host (IP) and clientAddr. + if (host, loc["clientAddr"]) in known_devices: + _LOGGER.debug("Discovered device %s on host %s with " + "client address %s is already " + "configured", + str.title(loc["locationName"]), + host, loc["clientAddr"]) + else: + _LOGGER.debug("Adding discovered device %s with" + " client address %s", + str.title(loc["locationName"]), + loc["clientAddr"]) + hosts.append([str.title(loc["locationName"]), host, + DEFAULT_PORT, loc["clientAddr"]]) dtvs = [] for host in hosts: dtvs.append(DirecTvDevice(*host)) - known_devices.append(host[-1]) + hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3])) add_entities(dtvs) - hass.data[DATA_DIRECTV] = known_devices class DirecTvDevice(MediaPlayerDevice): @@ -103,29 +130,49 @@ class DirecTvDevice(MediaPlayerDevice): self._paused = None self._last_position = None self._is_recorded = None + self._is_client = device != '0' self._assumed_state = None + self._available = False - _LOGGER.debug("Created DirecTV device for %s", self._name) + if self._is_client: + _LOGGER.debug("Created DirecTV client %s for device %s", + self._name, device) + else: + _LOGGER.debug("Created DirecTV device for %s", self._name) def update(self): """Retrieve latest state.""" - _LOGGER.debug("Updating state for %s", self._name) - self._is_standby = self.dtv.get_standby() - if self._is_standby: - self._current = None - self._is_recorded = None - self._paused = None - self._assumed_state = False - self._last_position = None - self._last_update = None - else: - self._current = self.dtv.get_tuned() - self._is_recorded = self._current.get('uniqueId') is not None - self._paused = self._last_position == self._current['offset'] - self._assumed_state = self._is_recorded - self._last_position = self._current['offset'] - self._last_update = dt_util.now() if not self._paused or\ - self._last_update is None else self._last_update + _LOGGER.debug("Updating status for %s", self._name) + try: + self._available = True + self._is_standby = self.dtv.get_standby() + if self._is_standby: + self._current = None + self._is_recorded = None + self._paused = None + self._assumed_state = False + self._last_position = None + self._last_update = None + else: + self._current = self.dtv.get_tuned() + if self._current['status']['code'] == 200: + self._is_recorded = self._current.get('uniqueId')\ + is not None + self._paused = self._last_position == \ + self._current['offset'] + self._assumed_state = self._is_recorded + self._last_position = self._current['offset'] + self._last_update = dt_util.now() if not self._paused or\ + self._last_update is None else self._last_update + else: + self._available = False + except requests.RequestException as ex: + _LOGGER.error("Request error trying to update current status for" + " %s. %s", self._name, ex) + self._available = False + except Exception: + self._available = False + raise @property def device_state_attributes(self): @@ -160,6 +207,11 @@ class DirecTvDevice(MediaPlayerDevice): return STATE_PLAYING + @property + def available(self): + """Return if able to retrieve information from DVR or not.""" + return self._available + @property def assumed_state(self): """Return if we assume the state or not.""" @@ -247,7 +299,7 @@ class DirecTvDevice(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - return SUPPORT_DTV + return SUPPORT_DTV_CLIENT if self._is_client else SUPPORT_DTV @property def media_currently_recording(self): @@ -284,11 +336,17 @@ class DirecTvDevice(MediaPlayerDevice): def turn_on(self): """Turn on the receiver.""" + if self._is_client: + raise NotImplementedError() + _LOGGER.debug("Turn on %s", self._name) self.dtv.key_press('poweron') def turn_off(self): """Turn off the receiver.""" + if self._is_client: + raise NotImplementedError() + _LOGGER.debug("Turn off %s", self._name) self.dtv.key_press('poweroff') @@ -317,7 +375,12 @@ class DirecTvDevice(MediaPlayerDevice): _LOGGER.debug("Fast forward on %s", self._name) self.dtv.key_press('ffwd') - def select_source(self, source): + def play_media(self, media_type, media_id, **kwargs): """Select input source.""" - _LOGGER.debug("Changing channel on %s to %s", self._name, source) - self.dtv.tune_channel(source) + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error("Invalid media type %s. Only %s is supported", + media_type, MEDIA_TYPE_CHANNEL) + return + + _LOGGER.debug("Changing channel on %s to %s", self._name, media_id) + self.dtv.tune_channel(media_id) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 7e87925dcc7..941b8844f86 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -6,6 +6,7 @@ https://home-assistant.io/components/media_player.dlna_dmr/ """ import asyncio from datetime import datetime +from datetime import timedelta import functools import logging @@ -14,7 +15,7 @@ import voluptuous as vol from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, + SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import ( CONF_NAME, CONF_URL, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, @@ -25,7 +26,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.13.0'] +REQUIREMENTS = ['async-upnp-client==0.13.2'] _LOGGER = logging.getLogger(__name__) @@ -233,6 +234,8 @@ class DlnaDmrDevice(MediaPlayerDevice): supported_features |= SUPPORT_NEXT_TRACK if self._device.has_play_media: supported_features |= SUPPORT_PLAY_MEDIA + if self._device.has_seek_rel_time: + supported_features |= SUPPORT_SEEK return supported_features @@ -284,6 +287,16 @@ class DlnaDmrDevice(MediaPlayerDevice): await self._device.async_stop() + @catch_request_errors() + async def async_media_seek(self, position): + """Send seek command.""" + if not self._device.can_seek_rel_time: + _LOGGER.debug('Cannot do Seek/rel_time') + return + + time = timedelta(seconds=position) + await self._device.async_seek_rel_time(time) + @catch_request_errors() async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" @@ -295,7 +308,7 @@ class DlnaDmrDevice(MediaPlayerDevice): if self._device.can_stop: await self.async_media_stop() - # +ueue media + # Queue media await self._device.async_set_transport_uri( media_id, title, mime_type, upnp_class) await self._device.async_wait_for_can_play() diff --git a/homeassistant/components/media_player/epson.py b/homeassistant/components/media_player/epson.py index 46beb4487fd..bb1618f2351 100644 --- a/homeassistant/components/media_player/epson.py +++ b/homeassistant/components/media_player/epson.py @@ -75,7 +75,7 @@ async def async_setup_platform( if service.service == SERVICE_SELECT_CMODE: cmode = service.data.get(ATTR_CMODE) await device.select_cmode(cmode) - await device.update() + device.async_schedule_update_ha_state(True) epson_schema = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET)) @@ -102,7 +102,7 @@ class EpsonProjector(MediaPlayerDevice): self._volume = None self._state = None - async def update(self): + async def async_update(self): """Update state of device.""" from epson_projector.const import ( EPSON_CODES, POWER, CMODE, CMODE_LIST, SOURCE, VOLUME, BUSY, diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 3914d2381b2..0c1984b3bce 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -4,166 +4,147 @@ 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 requests +import threading import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, - SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, MediaPlayerDevice) + MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, ) from homeassistant.const import ( - CONF_DEVICE, CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, - STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, - STATE_UNKNOWN) + 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.7'] + _LOGGER = logging.getLogger(__name__) SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET | \ - SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP | \ + SUPPORT_VOLUME_SET | SUPPORT_PLAY + +CONF_ADBKEY = 'adbkey' +CONF_GET_SOURCE = 'get_source' +CONF_GET_SOURCES = 'get_sources' -DEFAULT_SSL = False -DEFAULT_DEVICE = 'default' -DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Amazon Fire TV' -DEFAULT_PORT = 5556 -DEVICE_ACTION_URL = '{0}://{1}:{2}/devices/action/{3}/{4}' -DEVICE_LIST_URL = '{0}://{1}:{2}/devices/list' -DEVICE_STATE_URL = '{0}://{1}:{2}/devices/state/{3}' -DEVICE_APPS_URL = '{0}://{1}:{2}/devices/{3}/apps/{4}' +DEFAULT_PORT = 5555 +DEFAULT_GET_SOURCE = True +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.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + 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_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_ADBKEY): has_adb_files, + vol.Optional(CONF_GET_SOURCE, default=DEFAULT_GET_SOURCE): cv.boolean, + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean }) +PACKAGE_LAUNCHER = "com.amazon.tv.launcher" +PACKAGE_SETTINGS = "com.amazon.tv.settings" + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the FireTV platform.""" - name = config.get(CONF_NAME) - ssl = config.get(CONF_SSL) - proto = 'https' if ssl else 'http' - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - device_id = config.get(CONF_DEVICE) + from firetv import FireTV - try: - response = requests.get( - DEVICE_LIST_URL.format(proto, host, port)).json() - if device_id in response[CONF_DEVICES].keys(): - add_entities([FireTVDevice(proto, host, port, device_id, name)]) - _LOGGER.info("Device %s accessible and ready for control", - device_id) - else: - _LOGGER.warning("Device %s is not registered with firetv-server", - device_id) - except requests.exceptions.RequestException: - _LOGGER.error("Could not connect to firetv-server at %s", host) + host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) + + 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 = "" + + if not ftv.available: + _LOGGER.warning("Could not connect to Fire TV at %s%s", host, adb_log) + return + + name = config[CONF_NAME] + get_source = config[CONF_GET_SOURCE] + get_sources = config[CONF_GET_SOURCES] + + device = FireTVDevice(ftv, name, get_source, get_sources) + add_entities([device]) + _LOGGER.info("Setup Fire TV at %s%s", host, adb_log) -class FireTV: - """The firetv-server client. +def adb_decorator(override_available=False): + """Send an ADB command if the device is available and not locked.""" + def adb_wrapper(func): + """Wait if previous ADB commands haven't finished.""" + @functools.wraps(func) + def _adb_wrapper(self, *args, **kwargs): + # If the device is unavailable, don't do anything + if not self.available and not override_available: + return None - Should a native Python 3 ADB module become available, python-firetv can - support Python 3, it can be added as a dependency, and this class can be - dispensed of. + # If an ADB command is already running, skip this command + if not self.adb_lock.acquire(blocking=False): + _LOGGER.info('Skipping an ADB command because a previous ' + 'command is still running') + return None - For now, it acts as a client to the firetv-server HTTP server (which must - be running via Python 2). - """ + # Additional ADB commands will be prevented while trying this one + try: + returns = func(self, *args, **kwargs) + except self.exceptions: + _LOGGER.error('Failed to execute an ADB command; will attempt ' + 'to re-establish the ADB connection in the next ' + 'update') + returns = None + self._available = False # pylint: disable=protected-access + finally: + self.adb_lock.release() - def __init__(self, proto, host, port, device_id): - """Initialize the FireTV server.""" - self.proto = proto - self.host = host - self.port = port - self.device_id = device_id + return returns - @property - def state(self): - """Get the device state. An exception means UNKNOWN state.""" - try: - response = requests.get( - DEVICE_STATE_URL.format( - self.proto, self.host, self.port, self.device_id - ), timeout=10).json() - return response.get('state', STATE_UNKNOWN) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve device state for %s", self.device_id) - return STATE_UNKNOWN + return _adb_wrapper - @property - def current_app(self): - """Return the current app.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'current' - ), timeout=10).json() - _current_app = response.get('current_app') - if _current_app: - return _current_app.get('package') - - return None - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve current app for %s", self.device_id) - return None - - @property - def running_apps(self): - """Return a list of running apps.""" - try: - response = requests.get( - DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, 'running' - ), timeout=10).json() - return response.get('running_apps') - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not retrieve running apps for %s", self.device_id) - return None - - def action(self, action_id): - """Perform an action on the device.""" - try: - requests.get(DEVICE_ACTION_URL.format( - self.proto, self.host, self.port, self.device_id, action_id - ), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Action request for %s was not accepted for device %s", - action_id, self.device_id) - - def start_app(self, app_name): - """Start an app.""" - try: - requests.get(DEVICE_APPS_URL.format( - self.proto, self.host, self.port, self.device_id, - app_name + '/start'), timeout=10) - except requests.exceptions.RequestException: - _LOGGER.error( - "Could not start %s on %s", app_name, self.device_id) + return adb_wrapper class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" - def __init__(self, proto, host, port, device, name): + def __init__(self, ftv, name, get_source, get_sources): """Initialize the FireTV device.""" - self._firetv = FireTV(proto, host, port, device) + from adb.adb_protocol import ( + InvalidCommandError, InvalidResponseError, InvalidChecksumError) + + self.firetv = ftv + self._name = name - self._state = STATE_UNKNOWN - self._running_apps = None + self._get_source = get_source + self._get_sources = get_sources + + # whether or not the ADB connection is currently in use + self.adb_lock = threading.Lock() + + # ADB exceptions to catch + self.exceptions = (TypeError, ValueError, AttributeError, + InvalidCommandError, InvalidResponseError, + InvalidChecksumError) + + self._state = None + self._available = self.firetv.available self._current_app = None + self._running_apps = None @property def name(self): @@ -185,6 +166,11 @@ class FireTVDevice(MediaPlayerDevice): """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 source(self): """Return the current app.""" @@ -195,60 +181,135 @@ class FireTVDevice(MediaPlayerDevice): """Return a list of running apps.""" return self._running_apps + @adb_decorator(override_available=True) def update(self): """Get the latest date and update device state.""" - self._state = { - 'idle': STATE_IDLE, - 'off': STATE_OFF, - 'play': STATE_PLAYING, - 'pause': STATE_PAUSED, - 'standby': STATE_STANDBY, - 'disconnected': STATE_UNKNOWN, - }.get(self._firetv.state, STATE_UNKNOWN) - - if self._state not in [STATE_OFF, STATE_UNKNOWN]: - self._running_apps = self._firetv.running_apps - self._current_app = self._firetv.current_app - else: + # Check if device is disconnected. + if not self._available: self._running_apps = None self._current_app = None + # Try to connect + self.firetv.connect() + self._available = self.firetv.available + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Check if device is off. + if not self.firetv.screen_on: + self._state = STATE_OFF + self._running_apps = None + self._current_app = None + + # Check if screen saver is on. + elif not self.firetv.awake: + self._state = STATE_IDLE + self._running_apps = None + self._current_app = None + + else: + # Get the running apps. + if self._get_sources: + self._running_apps = self.firetv.running_apps + + # Get the current app. + if self._get_source: + current_app = self.firetv.current_app + if isinstance(current_app, dict)\ + and 'package' in current_app: + self._current_app = current_app['package'] + else: + self._current_app = current_app + + # Show the current app as the only running app. + if not self._get_sources: + if self._current_app: + self._running_apps = [self._current_app] + else: + self._running_apps = None + + # Check if the launcher is active. + if self._current_app in [PACKAGE_LAUNCHER, + PACKAGE_SETTINGS]: + self._state = STATE_STANDBY + + # Check for a wake lock (device is playing). + elif self.firetv.wake_lock: + self._state = STATE_PLAYING + + # Otherwise, device is paused. + else: + self._state = STATE_PAUSED + + # Don't get the current app. + elif self.firetv.wake_lock: + # Check for a wake lock (device is playing). + self._state = STATE_PLAYING + else: + # Assume the devices is on standby. + self._state = STATE_STANDBY + + @adb_decorator() def turn_on(self): """Turn on the device.""" - self._firetv.action('turn_on') + self.firetv.turn_on() + @adb_decorator() def turn_off(self): """Turn off the device.""" - self._firetv.action('turn_off') + self.firetv.turn_off() + @adb_decorator() def media_play(self): """Send play command.""" - self._firetv.action('media_play') + self.firetv.media_play() + @adb_decorator() def media_pause(self): """Send pause command.""" - self._firetv.action('media_pause') + self.firetv.media_pause() + @adb_decorator() def media_play_pause(self): """Send play/pause command.""" - self._firetv.action('media_play_pause') + 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.action('volume_up') + self.firetv.volume_up() + @adb_decorator() def volume_down(self): """Send volume down command.""" - self._firetv.action('volume_down') + self.firetv.volume_down() + @adb_decorator() def media_previous_track(self): """Send previous track command (results in rewind).""" - self._firetv.action('media_previous') + self.firetv.media_previous() + @adb_decorator() def media_next_track(self): """Send next track command (results in fast-forward).""" - self._firetv.action('media_next') + self.firetv.media_next() + @adb_decorator() def select_source(self, source): - """Select input source.""" - self._firetv.start_app(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/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 01d8069dc3b..a83287eb617 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -33,6 +33,7 @@ from homeassistant.helpers import script from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.template import Template from homeassistant.util.yaml import dump +import homeassistant.util.dt as dt_util REQUIREMENTS = ['jsonrpc-async==0.6', 'jsonrpc-websocket==0.6'] @@ -281,6 +282,8 @@ class KodiDevice(MediaPlayerDevice): self.hass = hass self._name = name self._unique_id = unique_id + self._media_position_updated_at = None + self._media_position = None kwargs = { 'timeout': timeout, @@ -313,6 +316,7 @@ class KodiDevice(MediaPlayerDevice): self._ws_server.Player.OnAVChange = self.async_on_speed_event self._ws_server.Player.OnResume = self.async_on_speed_event self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event + self._ws_server.Player.OnSeek = self.async_on_speed_event self._ws_server.Player.OnStop = self.async_on_stop self._ws_server.Application.OnVolumeChanged = \ self.async_on_volume_changed @@ -371,6 +375,8 @@ class KodiDevice(MediaPlayerDevice): self._players = [] self._properties = {} self._item = {} + self._media_position_updated_at = None + self._media_position = None self.async_schedule_update_ha_state() @callback @@ -473,6 +479,11 @@ class KodiDevice(MediaPlayerDevice): ['time', 'totaltime', 'speed', 'live'] ) + position = self._properties['time'] + if self._media_position != position: + self._media_position_updated_at = dt_util.utcnow() + self._media_position = position + self._item = (await self.server.Player.GetItem( player_id, ['title', 'file', 'uniqueid', 'thumbnail', 'artist', @@ -482,6 +493,8 @@ class KodiDevice(MediaPlayerDevice): self._properties = {} self._item = {} self._app_properties = {} + self._media_position = None + self._media_position_updated_at = None @property def server(self): @@ -543,6 +556,24 @@ class KodiDevice(MediaPlayerDevice): total_time['minutes'] * 60 + total_time['seconds']) + @property + def media_position(self): + """Position of current playing media in seconds.""" + time = self._properties.get('time') + + if time is None: + return None + + return ( + time['hours'] * 3600 + + time['minutes'] * 60 + + time['seconds']) + + @property + def media_position_updated_at(self): + """Last valid time of media position.""" + return self._media_position_updated_at + @property def media_image_url(self): """Image url of current playing media.""" diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 367ad2aa972..5ff54201b3c 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -220,6 +220,9 @@ class OnkyoDevice(MediaPlayerDevice): [i for i in current_source_tuples[1]]) self._muted = bool(mute_raw[1] == 'on') self._volume = volume_raw[1] / self._max_volume + + if not hdmi_out_raw: + return self._attributes["video_out"] = ','.join(hdmi_out_raw[1]) @property diff --git a/homeassistant/components/media_player/panasonic_bluray.py b/homeassistant/components/media_player/panasonic_bluray.py new file mode 100644 index 00000000000..bcd34f162c7 --- /dev/null +++ b/homeassistant/components/media_player/panasonic_bluray.py @@ -0,0 +1,154 @@ +""" +Support for Panasonic Blu-Ray players. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/panasonic_bluray/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + PLATFORM_SCHEMA, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.util.dt import utcnow + +REQUIREMENTS = ['panacotta==0.1'] + +DEFAULT_NAME = "Panasonic Blu-Ray" +SCAN_INTERVAL = timedelta(seconds=30) + +_LOGGER = logging.getLogger(__name__) + +SUPPORT_PANASONIC_BD = (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY | + SUPPORT_STOP | SUPPORT_PAUSE) + +# No host is needed for configuration, however it can be set. +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Panasonic Blu-Ray platform.""" + conf = discovery_info if discovery_info else config + + # Register configured device with Home Assistant. + add_entities([PanasonicBluRay(conf[CONF_HOST], conf[CONF_NAME])]) + + +class PanasonicBluRay(MediaPlayerDevice): + """Represent Panasonic Blu-Ray devices for Home Assistant.""" + + def __init__(self, ip, name): + """Receive IP address and name to construct class.""" + # Import panacotta library. + import panacotta + + # Initialize the Panasonic device. + self._device = panacotta.PanasonicBD(ip) + # Default name value, only to be overridden by user. + self._name = name + # Assume we're off to start with + self._state = STATE_OFF + self._position = 0 + self._duration = 0 + self._position_valid = 0 + + @property + def icon(self): + """Return a disc player icon for the device.""" + return 'mdi:disc-player' + + @property + def name(self): + """Return the display name of this device.""" + return self._name + + @property + def state(self): + """Return _state variable, containing the appropriate constant.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_PANASONIC_BD + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid.""" + return self._position_valid + + def update(self): + """Update the internal state by querying the device.""" + # This can take 5+ seconds to complete + state = self._device.get_play_status() + + if state[0] == 'error': + self._state = STATE_UNKNOWN + elif state[0] in ['off', 'standby']: + # We map both of these to off. If it's really off we can't + # turn it on, but from standby we can go to idle by pressing + # POWER. + self._state = STATE_OFF + elif state[0] in ['paused', 'stopped']: + self._state = STATE_IDLE + elif state[0] == 'playing': + self._state = STATE_PLAYING + + # Update our current media position + length + if state[1] >= 0: + self._position = state[1] + else: + self._position = 0 + self._position_valid = utcnow() + self._duration = state[2] + + def turn_off(self): + """ + Instruct the device to turn standby. + + Sending the "POWER" button will turn the device to standby - there + is no way to turn it completely off remotely. However this works in + our favour as it means the device is still accepting commands and we + can thus turn it back on when desired. + """ + if self._state != STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_OFF + + def turn_on(self): + """Wake the device back up from standby.""" + if self._state == STATE_OFF: + self._device.send_key('POWER') + + self._state = STATE_IDLE + + def media_play(self): + """Send play command.""" + self._device.send_key('PLAYBACK') + + def media_pause(self): + """Send pause command.""" + self._device.send_key('PAUSE') + + def media_stop(self): + """Send stop command.""" + self._device.send_key('STOP') diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 1d40683e51e..9def9875ab4 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -17,7 +17,8 @@ from homeassistant.components.media_player import ( SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF) + CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, + STATE_ON) import homeassistant.helpers.config_validation as cv from homeassistant.util import dt as dt_util @@ -153,11 +154,11 @@ class SamsungTVDevice(MediaPlayerDevice): BrokenPipeError): # BrokenPipe can occur when the commands is sent to fast self._remote = None - self._state = None + self._state = STATE_ON except (self._exceptions_class.UnhandledResponse, self._exceptions_class.AccessDenied): # We got a response so it's on. - self._state = None + self._state = STATE_ON self._remote = None _LOGGER.debug("Failed sending command %s", key, exc_info=True) return diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index 28ff269f400..b34aabd4c51 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -11,6 +11,7 @@ import socket import threading import urllib +import requests import voluptuous as vol from homeassistant.components.media_player import ( @@ -30,6 +31,8 @@ DEPENDENCIES = ('sonos',) _LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + # Quiet down pysonos logging to just actual problems. logging.getLogger('pysonos').setLevel(logging.WARNING) logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR) @@ -334,6 +337,7 @@ class SonosDevice(MediaPlayerDevice): def __init__(self, player): """Initialize the Sonos device.""" + self._subscriptions = [] self._receives_events = False self._volume_increment = 2 self._unique_id = player.uid @@ -481,17 +485,16 @@ class SonosDevice(MediaPlayerDevice): player = self.soco - queue = _ProcessSonosEventQueue(self.update_media) - player.avTransport.subscribe(auto_renew=True, event_queue=queue) + def subscribe(service, action): + """Add a subscription to a pysonos service.""" + queue = _ProcessSonosEventQueue(action) + sub = service.subscribe(auto_renew=True, event_queue=queue) + self._subscriptions.append(sub) - queue = _ProcessSonosEventQueue(self.update_volume) - player.renderingControl.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.update_groups) - player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) - - queue = _ProcessSonosEventQueue(self.update_content) - player.contentDirectory.subscribe(auto_renew=True, event_queue=queue) + subscribe(player.avTransport, self.update_media) + subscribe(player.renderingControl, self.update_volume) + subscribe(player.zoneGroupTopology, self.update_groups) + subscribe(player.contentDirectory, self.update_content) def update(self): """Retrieve latest state.""" @@ -502,6 +505,10 @@ class SonosDevice(MediaPlayerDevice): self._set_basic_information() self._subscribe_to_player_events() else: + for subscription in self._subscriptions: + self.hass.async_add_executor_job(subscription.unsubscribe) + self._subscriptions = [] + self._player_volume = None self._player_muted = None self._status = 'OFF' @@ -706,16 +713,19 @@ class SonosDevice(MediaPlayerDevice): if group: # New group information is pushed coordinator_uid, *slave_uids = group.split(',') - elif self.soco.group: - # Use SoCo cache for existing topology - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [p.uid for p in self.soco.group.members - if p.uid != coordinator_uid] else: - # Not yet in the cache, this can happen when a speaker boots 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: sonos_group = [] for uid in (coordinator_uid, *slave_uids): diff --git a/homeassistant/components/media_player/ziggo_mediabox_xl.py b/homeassistant/components/media_player/ziggo_mediabox_xl.py index 555042bee5c..57ef69c923e 100644 --- a/homeassistant/components/media_player/ziggo_mediabox_xl.py +++ b/homeassistant/components/media_player/ziggo_mediabox_xl.py @@ -14,10 +14,10 @@ from homeassistant.components.media_player import ( SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, MediaPlayerDevice) from homeassistant.const import ( - CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) + CONF_HOST, CONF_NAME, STATE_OFF, STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['ziggo-mediabox-xl==1.0.0'] +REQUIREMENTS = ['ziggo-mediabox-xl==1.1.0'] _LOGGER = logging.getLogger(__name__) @@ -43,9 +43,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if config.get(CONF_HOST) is not None: host = config.get(CONF_HOST) name = config.get(CONF_NAME) + manual_config = True elif discovery_info is not None: host = discovery_info.get('host') name = discovery_info.get('name') + manual_config = False else: _LOGGER.error("Cannot determine device") return @@ -53,15 +55,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Only add a device once, so discovered devices do not override manual # config. hosts = [] + connection_successful = False ip_addr = socket.gethostbyname(host) if ip_addr not in known_devices: try: - mediabox = ZiggoMediaboxXL(ip_addr) + # Mediabox instance with a timeout of 3 seconds. + mediabox = ZiggoMediaboxXL(ip_addr, 3) + # Check if a connection can be established to the device. if mediabox.test_connection(): - hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name)) - known_devices.add(ip_addr) + connection_successful = True else: - _LOGGER.error("Can't connect to %s", host) + if manual_config: + _LOGGER.info("Can't connect to %s", host) + else: + _LOGGER.error("Can't connect to %s", host) + # When the device is in eco mode it's not connected to the network + # so it needs to be added anyway if it's configured manually. + if manual_config or connection_successful: + hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name, + connection_successful)) + known_devices.add(ip_addr) except socket.error as error: _LOGGER.error("Can't connect to %s: %s", host, error) else: @@ -72,24 +85,29 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class ZiggoMediaboxXLDevice(MediaPlayerDevice): """Representation of a Ziggo Mediabox XL Device.""" - def __init__(self, mediabox, host, name): + def __init__(self, mediabox, host, name, available): """Initialize the device.""" - # Generate a configuration for the Samsung library self._mediabox = mediabox self._host = host self._name = name + self._available = available self._state = None def update(self): """Retrieve the state of the device.""" try: - if self._mediabox.turned_on(): - if self._state != STATE_PAUSED: - self._state = STATE_PLAYING + if self._mediabox.test_connection(): + if self._mediabox.turned_on(): + if self._state != STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + self._available = True else: - self._state = STATE_OFF + self._available = False except socket.error: _LOGGER.error("Couldn't fetch state from %s", self._host) + self._available = False def send_keys(self, keys): """Send keys to the device and handle exceptions.""" @@ -108,6 +126,11 @@ class ZiggoMediaboxXLDevice(MediaPlayerDevice): """Return the state of the device.""" return self._state + @property + def available(self): + """Return True if the device is available.""" + return self._available + @property def source_list(self): """List of available sources (channels).""" @@ -122,12 +145,10 @@ class ZiggoMediaboxXLDevice(MediaPlayerDevice): def turn_on(self): """Turn the media player on.""" self.send_keys(['POWER']) - self._state = STATE_ON def turn_off(self): """Turn off media player.""" self.send_keys(['POWER']) - self._state = STATE_OFF def media_play(self): """Send play command.""" diff --git a/homeassistant/components/melissa.py b/homeassistant/components/melissa.py index da2ec49d11f..638d8c55bd5 100644 --- a/homeassistant/components/melissa.py +++ b/homeassistant/components/melissa.py @@ -39,8 +39,6 @@ async def async_setup(hass, config): await api.async_connect() hass.data[DATA_MELISSA] = api - hass.async_create_task( - async_load_platform(hass, 'sensor', DOMAIN, {}, config)) hass.async_create_task( async_load_platform(hass, 'climate', DOMAIN, {}, config)) return True diff --git a/homeassistant/components/mqtt/.translations/cs.json b/homeassistant/components/mqtt/.translations/cs.json new file mode 100644 index 00000000000..e76577a5dc8 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Je povolena pouze jedin\u00e1 konfigurace MQTT." + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit k brokeru." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed", + "password": "Heslo", + "port": "Port", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + }, + "title": "MQTT" + }, + "hassio_confirm": { + "data": { + "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed" + }, + "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k zprost\u0159edkovateli MQTT poskytovan\u00e9mu dopl\u0148kem hass.io {addon}?", + "title": "MQTT Broker prost\u0159ednictv\u00edm dopl\u0148ku Hass.io" + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index 182cce86057..7af8f43b897 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -1,6 +1,23 @@ { "config": { + "abort": { + "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT." + }, + "error": { + "cannot_connect": "No se puede conectar con el agente" + }, "step": { + "broker": { + "data": { + "broker": "Agente", + "discovery": "Habilitar descubrimiento", + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Usuario" + }, + "description": "Por favor, introduce la informaci\u00f3n de tu agente MQTT", + "title": "MQTT" + }, "hassio_confirm": { "data": { "discovery": "Habilitar descubrimiento" @@ -8,6 +25,7 @@ "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?", "title": "MQTT Broker a trav\u00e9s del complemento Hass.io" } - } + }, + "title": "MQTT" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/it.json b/homeassistant/components/mqtt/.translations/it.json new file mode 100644 index 00000000000..e56860cd675 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di MQTT." + }, + "error": { + "cannot_connect": "Impossibile connettersi al broker." + }, + "step": { + "broker": { + "data": { + "broker": "Broker", + "discovery": "Attiva l'individuazione" + } + }, + "hassio_confirm": { + "data": { + "discovery": "Attiva l'individuazione" + } + } + }, + "title": "MQTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 8b0ed27f3ae..7e35c219c45 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -10,7 +10,7 @@ "broker": { "data": { "broker": "\u0411\u0440\u043e\u043a\u0435\u0440", - "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435", + "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "port": "\u041f\u043e\u0440\u0442", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" @@ -20,7 +20,7 @@ }, "hassio_confirm": { "data": { - "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u0435" + "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432" }, "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Home Assistant \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT \u0447\u0435\u0440\u0435\u0437 \u0430\u0434\u0434\u043e\u043d Hass.io {addon}?", "title": "\u0411\u0440\u043e\u043a\u0435\u0440 MQTT \u0447\u0435\u0440\u0435\u0437 \u0430\u0434\u0434\u043e\u043d Hass.io" diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e06e025c7ab..66b10532664 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -832,12 +832,30 @@ class MqttAvailability(Entity): self._available = availability_topic is None # type: bool self._payload_available = payload_available self._payload_not_available = payload_not_available + self._availability_sub_state = None async def async_added_to_hass(self) -> None: """Subscribe MQTT events. This method must be run in the event loop and returns a coroutine. """ + await self._availability_subscribe_topics() + + async def availability_discovery_update(self, config: dict): + """Handle updated discovery message.""" + self._availability_setup_from_config(config) + await self._availability_subscribe_topics() + + def _availability_setup_from_config(self, config): + """(Re)Setup.""" + self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + + async def _availability_subscribe_topics(self): + """(Re)Subscribe to topics.""" + from .subscription import async_subscribe_topics + @callback def availability_message_received(topic: str, payload: SubscribePayloadType, @@ -850,10 +868,17 @@ class MqttAvailability(Entity): self.async_schedule_update_ha_state() - if self._availability_topic is not None: - await async_subscribe( - self.hass, self._availability_topic, - availability_message_received, self._availability_qos) + self._availability_sub_state = await async_subscribe_topics( + self.hass, self._availability_sub_state, + {'availability_topic': { + 'topic': self._availability_topic, + 'msg_callback': availability_message_received, + 'qos': self._availability_qos}}) + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + from .subscription import async_unsubscribe_topics + await async_unsubscribe_topics(self.hass, self._availability_sub_state) @property def available(self) -> bool: @@ -864,9 +889,10 @@ class MqttAvailability(Entity): class MqttDiscoveryUpdate(Entity): """Mixin used to handle updated discovery message.""" - def __init__(self, discovery_hash) -> None: + def __init__(self, discovery_hash, discovery_update=None) -> None: """Initialize the discovery update mixin.""" self._discovery_hash = discovery_hash + self._discovery_update = discovery_update self._remove_signal = None async def async_added_to_hass(self) -> None: @@ -886,6 +912,10 @@ class MqttDiscoveryUpdate(Entity): self.hass.async_create_task(self.async_remove()) del self.hass.data[ALREADY_DISCOVERED][self._discovery_hash] self._remove_signal() + elif self._discovery_update: + # Non-empty payload: Notify component + _LOGGER.info("Updating component: %s", self.entity_id) + self.hass.async_create_task(self._discovery_update(payload)) if self._discovery_hash: self._remove_signal = async_dispatcher_connect( diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b8c8627c038..d91ab6ee445 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -45,6 +45,7 @@ CONFIG_ENTRY_PLATFORMS = { 'camera': ['mqtt'], 'cover': ['mqtt'], 'light': ['mqtt'], + 'lock': ['mqtt'], 'sensor': ['mqtt'], 'switch': ['mqtt'], 'climate': ['mqtt'], @@ -201,20 +202,38 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, if TOPIC_BASE in payload: base = payload[TOPIC_BASE] for key, value in payload.items(): - if value[0] == TOPIC_BASE and key.endswith('_topic'): - payload[key] = "{}{}".format(base, value[1:]) - if value[-1] == TOPIC_BASE and key.endswith('_topic'): - payload[key] = "{}{}".format(value[:-1], base) + if isinstance(value, str): + if value[0] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(base, value[1:]) + if value[-1] == TOPIC_BASE and key.endswith('_topic'): + payload[key] = "{}{}".format(value[:-1], base) - # If present, the node_id will be included in the discovered object id - discovery_id = '_'.join((node_id, object_id)) if node_id else object_id + # If present, unique_id is used as the discovered object id. Otherwise, + # if present, the node_id will be included in the discovered object id + discovery_id = payload.get( + 'unique_id', ' '.join( + (node_id, object_id)) if node_id else object_id) + discovery_hash = (component, discovery_id) + + if payload: + platform = payload.get(CONF_PLATFORM, 'mqtt') + if platform not in ALLOWED_PLATFORMS.get(component, []): + _LOGGER.warning("Platform %s (component %s) is not allowed", + platform, component) + return + payload[CONF_PLATFORM] = platform + + if CONF_STATE_TOPIC not in payload: + payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( + discovery_topic, component, + '%s/' % node_id if node_id else '', object_id) + + payload[ATTR_DISCOVERY_HASH] = discovery_hash if ALREADY_DISCOVERED not in hass.data: hass.data[ALREADY_DISCOVERED] = {} - - discovery_hash = (component, discovery_id) - if discovery_hash in hass.data[ALREADY_DISCOVERED]: + # Dispatch update _LOGGER.info( "Component has already been discovered: %s %s, sending update", component, discovery_id) @@ -222,22 +241,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload) elif payload: # Add component - platform = payload.get(CONF_PLATFORM, 'mqtt') - if platform not in ALLOWED_PLATFORMS.get(component, []): - _LOGGER.warning("Platform %s (component %s) is not allowed", - platform, component) - return - - payload[CONF_PLATFORM] = platform - if CONF_STATE_TOPIC not in payload: - payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( - discovery_topic, component, - '%s/' % node_id if node_id else '', object_id) - - hass.data[ALREADY_DISCOVERED][discovery_hash] = None - payload[ATTR_DISCOVERY_HASH] = discovery_hash - _LOGGER.info("Found new component: %s %s", component, discovery_id) + hass.data[ALREADY_DISCOVERED][discovery_hash] = None if platform not in CONFIG_ENTRY_PLATFORMS.get(component, []): await async_load_platform( diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py new file mode 100644 index 00000000000..8be8d311d9b --- /dev/null +++ b/homeassistant/components/mqtt/subscription.py @@ -0,0 +1,54 @@ +""" +Helper to handle a set of topics to subscribe to. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mqtt/ +""" +import logging + +from homeassistant.components import mqtt +from homeassistant.components.mqtt import DEFAULT_QOS +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_subscribe_topics(hass: HomeAssistantType, sub_state: dict, + topics: dict): + """(Re)Subscribe to a set of MQTT topics. + + State is kept in sub_state. + """ + cur_state = sub_state if sub_state is not None else {} + sub_state = {} + for key in topics: + topic = topics[key].get('topic', None) + msg_callback = topics[key].get('msg_callback', None) + qos = topics[key].get('qos', DEFAULT_QOS) + encoding = topics[key].get('encoding', 'utf-8') + topic = (topic, msg_callback, qos, encoding) + (cur_topic, unsub) = cur_state.pop( + key, ((None, None, None, None), None)) + + if topic != cur_topic and topic[0] is not None: + if unsub is not None: + unsub() + unsub = await mqtt.async_subscribe( + hass, topic[0], topic[1], topic[2], topic[3]) + sub_state[key] = (topic, unsub) + + for key, (topic, unsub) in list(cur_state.items()): + if unsub is not None: + unsub() + + return sub_state + + +@bind_hass +async def async_unsubscribe_topics(hass: HomeAssistantType, sub_state: dict): + """Unsubscribe from all MQTT topics managed by async_subscribe_topics.""" + await async_subscribe_topics(hass, sub_state, {}) + + return sub_state diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 883175340ce..49f8560c6b3 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -22,7 +22,7 @@ from .const import ( from .device import get_mysensors_devices from .gateway import get_mysensors_gateway, setup_gateways, finish_setup -REQUIREMENTS = ['pymysensors==0.17.0'] +REQUIREMENTS = ['pymysensors==0.18.0'] _LOGGER = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def setup_mysensors_platform( # Only act if called via MySensors by discovery event. # Otherwise gateway is not set up. if not discovery_info: - return + return None if device_args is None: device_args = () new_devices = [] diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 4f9718a39db..ccb54bf647f 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -16,10 +16,12 @@ CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' CONF_VERSION = 'version' DOMAIN = 'mysensors' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' MYSENSORS_GATEWAYS = 'mysensors_gateways' PLATFORM = 'platform' SCHEMA = 'schema' -SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +CHILD_CALLBACK = 'mysensors_child_callback_{}_{}_{}_{}' +NODE_CALLBACK = 'mysensors_node_callback_{}_{}' TYPE = 'type' # MySensors const schemas diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 3ae99f61d17..07261b1c2a6 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -7,7 +7,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import SIGNAL_CALLBACK +from .const import CHILD_CALLBACK, NODE_CALLBACK _LOGGER = logging.getLogger(__name__) @@ -15,6 +15,7 @@ ATTR_CHILD_ID = 'child_id' ATTR_DESCRIPTION = 'description' ATTR_DEVICE = 'device' ATTR_NODE_ID = 'node_id' +ATTR_HEARTBEAT = 'heartbeat' MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' @@ -51,6 +52,7 @@ class MySensorsDevice: child = node.children[self.child_id] attr = { ATTR_BATTERY_LEVEL: node.battery_level, + ATTR_HEARTBEAT: node.heartbeat, ATTR_CHILD_ID: self.child_id, ATTR_DESCRIPTION: child.description, ATTR_DEVICE: self.gateway.device, @@ -103,7 +105,11 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self): """Register update callback.""" - dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + gateway_id = id(self.gateway) + dev_id = gateway_id, self.node_id, self.child_id, self.value_type async_dispatcher_connect( - self.hass, SIGNAL_CALLBACK.format(*dev_id), + self.hass, CHILD_CALLBACK.format(*dev_id), + self.async_update_callback) + async_dispatcher_connect( + self.hass, NODE_CALLBACK.format(gateway_id, self.node_id), self.async_update_callback) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index cb1dad922f8..d4a52655d19 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -4,32 +4,28 @@ from collections import defaultdict import logging import socket import sys -from timeit import default_timer as timer import async_timeout import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) + CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from .const import ( - ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, + CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, - MYSENSORS_CONST_SCHEMA, MYSENSORS_GATEWAYS, PLATFORM, SCHEMA, - SIGNAL_CALLBACK, TYPE) -from .device import get_mysensors_devices + MYSENSORS_GATEWAY_READY, MYSENSORS_GATEWAYS) +from .handler import HANDLERS +from .helpers import discover_mysensors_platform, validate_child _LOGGER = logging.getLogger(__name__) GATEWAY_READY_TIMEOUT = 15.0 MQTT_COMPONENT = 'mqtt' -MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' def is_serial_port(value): @@ -167,25 +163,16 @@ async def _discover_persistent_devices(hass, hass_config, gateway): for node_id in gateway.sensors: node = gateway.sensors[node_id] for child in node.children.values(): - validated = _validate_child(gateway, node_id, child) + validated = validate_child(gateway, node_id, child) for platform, dev_ids in validated.items(): new_devices[platform].extend(dev_ids) for platform, dev_ids in new_devices.items(): - tasks.append(_discover_mysensors_platform( + tasks.append(discover_mysensors_platform( hass, hass_config, platform, dev_ids)) if tasks: await asyncio.wait(tasks, loop=hass.loop) -@callback -def _discover_mysensors_platform(hass, hass_config, platform, new_devices): - """Discover a MySensors platform.""" - task = hass.async_create_task(discovery.async_load_platform( - hass, platform, DOMAIN, - {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, hass_config)) - return task - - async def _gw_start(hass, gateway): """Start the gateway.""" # Don't use hass.async_create_task to avoid holding up setup indefinitely. @@ -222,112 +209,15 @@ def _gw_callback_factory(hass, hass_config): @callback def mysensors_callback(msg): """Handle messages from a MySensors gateway.""" - start = timer() _LOGGER.debug( "Node update: node %s child %s", msg.node_id, msg.child_id) - _set_gateway_ready(hass, msg) + msg_type = msg.gateway.const.MessageType(msg.type) + msg_handler = HANDLERS.get(msg_type.name) - try: - child = msg.gateway.sensors[msg.node_id].children[msg.child_id] - except KeyError: - _LOGGER.debug("Not a child update for node %s", msg.node_id) + if msg_handler is None: return - signals = [] + hass.async_create_task(msg_handler(hass, hass_config, msg)) - # Update all platforms for the device via dispatcher. - # Add/update entity if schema validates to true. - validated = _validate_child(msg.gateway, msg.node_id, child) - for platform, dev_ids in validated.items(): - devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] - for dev_id in dev_ids: - if dev_id in devices: - signals.append(SIGNAL_CALLBACK.format(*dev_id)) - else: - new_dev_ids.append(dev_id) - if new_dev_ids: - _discover_mysensors_platform( - hass, hass_config, platform, new_dev_ids) - for signal in set(signals): - # Only one signal per device is needed. - # A device can have multiple platforms, ie multiple schemas. - # FOR LATER: Add timer to not signal if another update comes in. - async_dispatcher_send(hass, signal) - end = timer() - if end - start > 0.1: - _LOGGER.debug( - "Callback for node %s child %s took %.3f seconds", - msg.node_id, msg.child_id, end - start) return mysensors_callback - - -@callback -def _set_gateway_ready(hass, msg): - """Set asyncio future result if gateway is ready.""" - if (msg.type != msg.gateway.const.MessageType.internal or - msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): - return - gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( - id(msg.gateway))) - if gateway_ready is None or gateway_ready.cancelled(): - return - gateway_ready.set_result(True) - - -def _validate_child(gateway, node_id, child): - """Validate that a child has the correct values according to schema. - - Return a dict of platform with a list of device ids for validated devices. - """ - validated = defaultdict(list) - - if not child.values: - _LOGGER.debug( - "No child values for node %s child %s", node_id, child.id) - return validated - if gateway.sensors[node_id].sketch_name is None: - _LOGGER.debug("Node %s is missing sketch name", node_id) - return validated - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - s_name = next( - (member.name for member in pres if member.value == child.type), None) - if s_name not in MYSENSORS_CONST_SCHEMA: - _LOGGER.warning("Child type %s is not supported", s_name) - return validated - child_schemas = MYSENSORS_CONST_SCHEMA[s_name] - - def msg(name): - """Return a message for an invalid schema.""" - return "{} requires value_type {}".format( - pres(child.type).name, set_req[name].name) - - for schema in child_schemas: - platform = schema[PLATFORM] - v_name = schema[TYPE] - value_type = next( - (member.value for member in set_req if member.name == v_name), - None) - if value_type is None: - continue - _child_schema = child.get_schema(gateway.protocol_version) - vol_schema = _child_schema.extend( - {vol.Required(set_req[key].value, msg=msg(key)): - _child_schema.schema.get(set_req[key].value, val) - for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, - extra=vol.ALLOW_EXTRA) - try: - vol_schema(child.values) - except vol.Invalid as exc: - level = (logging.WARNING if value_type in child.values - else logging.DEBUG) - _LOGGER.log( - level, - "Invalid values: %s: %s platform: node %s child %s: %s", - child.values, platform, node_id, child.id, exc) - continue - dev_id = id(gateway), node_id, child.id, value_type - validated[platform].append(dev_id) - return validated diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py new file mode 100644 index 00000000000..39af1173706 --- /dev/null +++ b/homeassistant/components/mysensors/handler.py @@ -0,0 +1,110 @@ +"""Handle MySensors messages.""" +import logging + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import decorator + +from .const import MYSENSORS_GATEWAY_READY, CHILD_CALLBACK, NODE_CALLBACK +from .device import get_mysensors_devices +from .helpers import discover_mysensors_platform, validate_child + +_LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + + +@HANDLERS.register('presentation') +async def handle_presentation(hass, hass_config, msg): + """Handle a mysensors presentation message.""" + # Handle both node and child presentation. + from mysensors.const import SYSTEM_CHILD_ID + if msg.child_id == SYSTEM_CHILD_ID: + return + _handle_child_update(hass, hass_config, msg) + + +@HANDLERS.register('set') +async def handle_set(hass, hass_config, msg): + """Handle a mysensors set message.""" + _handle_child_update(hass, hass_config, msg) + + +@HANDLERS.register('internal') +async def handle_internal(hass, hass_config, msg): + """Handle a mysensors internal message.""" + internal = msg.gateway.const.Internal(msg.sub_type) + handler = HANDLERS.get(internal.name) + if handler is None: + return + await handler(hass, hass_config, msg) + + +@HANDLERS.register('I_BATTERY_LEVEL') +async def handle_battery_level(hass, hass_config, msg): + """Handle an internal battery level message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_HEARTBEAT_RESPONSE') +async def handle_heartbeat(hass, hass_config, msg): + """Handle an heartbeat.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_SKETCH_NAME') +async def handle_sketch_name(hass, hass_config, msg): + """Handle an internal sketch name message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_SKETCH_VERSION') +async def handle_sketch_version(hass, hass_config, msg): + """Handle an internal sketch version message.""" + _handle_node_update(hass, msg) + + +@HANDLERS.register('I_GATEWAY_READY') +async def handle_gateway_ready(hass, hass_config, msg): + """Handle an internal gateway ready message. + + Set asyncio future result if gateway is ready. + """ + gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( + id(msg.gateway))) + if gateway_ready is None or gateway_ready.cancelled(): + return + gateway_ready.set_result(True) + + +@callback +def _handle_child_update(hass, hass_config, msg): + """Handle a child update.""" + child = msg.gateway.sensors[msg.node_id].children[msg.child_id] + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(CHILD_CALLBACK.format(*dev_id)) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + discover_mysensors_platform( + hass, hass_config, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + async_dispatcher_send(hass, signal) + + +@callback +def _handle_node_update(hass, msg): + """Handle a node update.""" + signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id) + async_dispatcher_send(hass, signal) diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py new file mode 100644 index 00000000000..a49967cf835 --- /dev/null +++ b/homeassistant/components/mysensors/helpers.py @@ -0,0 +1,81 @@ +"""Helper functions for mysensors package.""" +from collections import defaultdict +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_DEVICES, DOMAIN, MYSENSORS_CONST_SCHEMA, PLATFORM, SCHEMA, TYPE) + +_LOGGER = logging.getLogger(__name__) + + +@callback +def discover_mysensors_platform(hass, hass_config, platform, new_devices): + """Discover a MySensors platform.""" + task = hass.async_create_task(discovery.async_load_platform( + hass, platform, DOMAIN, + {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, hass_config)) + return task + + +def validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 38af84e3176..6c5fac074ba 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -31,29 +31,22 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) -STATES = { - 1: 'Idle', - 2: 'Busy', - 3: 'Pause', - 4: 'Error' -} - MODE = { 1: 'Eco', 2: 'Turbo' } ACTION = { - 0: 'No action', - 1: 'House cleaning', - 2: 'Spot cleaning', - 3: 'Manual cleaning', + 0: 'Invalid', + 1: 'House Cleaning', + 2: 'Spot Cleaning', + 3: 'Manual Cleaning', 4: 'Docking', - 5: 'User menu active', - 6: 'Cleaning cancelled', - 7: 'Updating...', - 8: 'Copying logs...', - 9: 'Calculating position...', + 5: 'User Menu Active', + 6: 'Suspended Cleaning', + 7: 'Updating', + 8: 'Copying logs', + 9: 'Recovering Location', 10: 'IEC test', 11: 'Map cleaning', 12: 'Exploring map (creating a persistent map)', @@ -98,7 +91,8 @@ ALERTS = { 'dustbin_full': 'Please empty dust bin', 'maint_brush_change': 'Change the brush', 'maint_filter_change': 'Change the filter', - 'clean_completed_to_start': 'Cleaning completed' + 'clean_completed_to_start': 'Cleaning completed', + 'nav_floorplan_not_created': 'No floorplan found' } diff --git a/homeassistant/components/nest/.translations/es.json b/homeassistant/components/nest/.translations/es.json index ceca4464e06..25af12a3bb8 100644 --- a/homeassistant/components/nest/.translations/es.json +++ b/homeassistant/components/nest/.translations/es.json @@ -1,10 +1,31 @@ { "config": { + "abort": { + "already_setup": "S\u00f3lo puedes configurar una \u00fanica cuenta de Nest.", + "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n", + "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.", + "no_flows": "Debe configurar Nest antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Error interno validando el c\u00f3digo", + "invalid_code": "C\u00f3digo inv\u00e1lido", + "timeout": "Tiempo de espera agotado validando el c\u00f3digo", + "unknown": "Error desconocido validando el c\u00f3digo" + }, "step": { + "init": { + "data": { + "flow_impl": "Proveedor" + }, + "description": "Elija a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Nest.", + "title": "Proveedor de autenticaci\u00f3n" + }, "link": { "data": { "code": "C\u00f3digo PIN" - } + }, + "description": "Para vincular su cuenta de Nest, [autorice su cuenta] ( {url} ). \n\n Despu\u00e9s de la autorizaci\u00f3n, copie y pegue el c\u00f3digo pin provisto a continuaci\u00f3n.", + "title": "Vincular cuenta de Nest" } }, "title": "Nest" diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index bb0e6247de3..5bbd36f4b9d 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -12,10 +12,12 @@ import threading import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.climate import ( + ATTR_AWAY_MODE, SERVICE_SET_AWAY_MODE) from homeassistant.const import ( - CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, - CONF_MONITORED_CONDITIONS, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) + CONF_BINARY_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, + CONF_SENSORS, CONF_STRUCTURE, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send, \ @@ -25,11 +27,13 @@ from homeassistant.helpers.entity import Entity from .const import DOMAIN from . import local_auth -REQUIREMENTS = ['python-nest==4.0.3'] +REQUIREMENTS = ['python-nest==4.0.5'] _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) +SERVICE_CANCEL_ETA = 'cancel_eta' +SERVICE_SET_ETA = 'set_eta' DATA_NEST = 'nest' DATA_NEST_CONFIG = 'nest_config' @@ -40,27 +44,18 @@ NEST_CONFIG_FILE = 'nest.conf' CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' -ATTR_HOME_MODE = 'home_mode' -ATTR_STRUCTURE = 'structure' -ATTR_TRIP_ID = 'trip_id' ATTR_ETA = 'eta' ATTR_ETA_WINDOW = 'eta_window' +ATTR_STRUCTURE = 'structure' +ATTR_TRIP_ID = 'trip_id' -HOME_MODE_AWAY = 'away' -HOME_MODE_HOME = 'home' +AWAY_MODE_AWAY = 'away' +AWAY_MODE_HOME = 'home' SENSOR_SCHEMA = vol.Schema({ vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) }) -AWAY_SCHEMA = vol.Schema({ - vol.Required(ATTR_HOME_MODE): vol.In([HOME_MODE_AWAY, HOME_MODE_HOME]), - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_TRIP_ID): cv.string, - vol.Optional(ATTR_ETA): cv.time_period, - vol.Optional(ATTR_ETA_WINDOW): cv.time_period -}) - CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_CLIENT_ID): cv.string, @@ -71,6 +66,23 @@ CONFIG_SCHEMA = vol.Schema({ }) }, extra=vol.ALLOW_EXTRA) +SET_AWAY_MODE_SCHEMA = vol.Schema({ + vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]), + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + +SET_ETA_SCHEMA = vol.Schema({ + vol.Required(ATTR_ETA): cv.time_period, + vol.Optional(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_ETA_WINDOW): cv.time_period, + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + +CANCEL_ETA_SCHEMA = vol.Schema({ + vol.Required(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]) +}) + def nest_update_event_broker(hass, nest): """ @@ -134,40 +146,83 @@ async def async_setup_entry(hass, entry): hass.async_create_task(hass.config_entries.async_forward_entry_setup( entry, component)) - def set_mode(service): - """ - Set the home/away mode for a Nest structure. + def validate_structures(target_structures): + all_structures = [structure.name for structure in nest.structures] + for target in target_structures: + if target not in all_structures: + _LOGGER.info("Invalid structure: %s", target) - You can set optional eta information when set mode to away. - """ + def set_away_mode(service): + """Set the away mode for a Nest structure.""" if ATTR_STRUCTURE in service.data: - structures = service.data[ATTR_STRUCTURE] + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) else: - structures = hass.data[DATA_NEST].local_structure + target_structures = hass.data[DATA_NEST].local_structure for structure in nest.structures: - if structure.name in structures: - _LOGGER.info("Setting mode for %s", structure.name) - structure.away = service.data[ATTR_HOME_MODE] + if structure.name in target_structures: + _LOGGER.info("Setting away mode for: %s to: %s", + structure.name, service.data[ATTR_AWAY_MODE]) + structure.away = service.data[ATTR_AWAY_MODE] + + def set_eta(service): + """Set away mode to away and include ETA for a Nest structure.""" + if ATTR_STRUCTURE in service.data: + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) + else: + target_structures = hass.data[DATA_NEST].local_structure + + for structure in nest.structures: + if structure.name in target_structures: + if structure.thermostats: + _LOGGER.info("Setting away mode for: %s to: %s", + structure.name, AWAY_MODE_AWAY) + structure.away = AWAY_MODE_AWAY - if service.data[ATTR_HOME_MODE] == HOME_MODE_AWAY \ - and ATTR_ETA in service.data: now = datetime.utcnow() + trip_id = service.data.get( + ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) eta_begin = now + service.data[ATTR_ETA] eta_window = service.data.get(ATTR_ETA_WINDOW, timedelta(minutes=1)) eta_end = eta_begin + eta_window - trip_id = service.data.get( - ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) - _LOGGER.info("Setting eta for %s, eta window starts at " - "%s ends at %s", trip_id, eta_begin, eta_end) + _LOGGER.info("Setting ETA for trip: %s, " + "ETA window starts at: %s and ends at: %s", + trip_id, eta_begin, eta_end) structure.set_eta(trip_id, eta_begin, eta_end) - else: - _LOGGER.error("Invalid structure %s", - service.data[ATTR_STRUCTURE]) + else: + _LOGGER.info("No thermostats found in structure: %s, " + "unable to set ETA", structure.name) + + def cancel_eta(service): + """Cancel ETA for a Nest structure.""" + if ATTR_STRUCTURE in service.data: + target_structures = service.data[ATTR_STRUCTURE] + validate_structures(target_structures) + else: + target_structures = hass.data[DATA_NEST].local_structure + + for structure in nest.structures: + if structure.name in target_structures: + if structure.thermostats: + trip_id = service.data[ATTR_TRIP_ID] + _LOGGER.info("Cancelling ETA for trip: %s", trip_id) + structure.cancel_eta(trip_id) + else: + _LOGGER.info("No thermostats found in structure: %s, " + "unable to cancel ETA", structure.name) hass.services.async_register( - DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode, + schema=SET_AWAY_MODE_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_SET_ETA, set_eta, schema=SET_ETA_SCHEMA) + + hass.services.async_register( + DOMAIN, SERVICE_CANCEL_ETA, cancel_eta, schema=CANCEL_ETA_SCHEMA) @callback def start_up(event): diff --git a/homeassistant/components/netgear_lte.py b/homeassistant/components/netgear_lte.py index e5e9a0fc2e9..7658015ea67 100644 --- a/homeassistant/components/netgear_lte.py +++ b/homeassistant/components/netgear_lte.py @@ -14,6 +14,7 @@ import aiohttp from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.util import Throttle @@ -39,11 +40,13 @@ CONFIG_SCHEMA = vol.Schema({ class ModemData: """Class for modem state.""" + host = attr.ib() modem = attr.ib() serial_number = attr.ib(init=False, default=None) unread_count = attr.ib(init=False, default=None) usage = attr.ib(init=False, default=None) + connected = attr.ib(init=False, default=True) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): @@ -54,7 +57,13 @@ class ModemData: self.serial_number = information.serial_number self.unread_count = sum(1 for x in information.sms if x.unread) self.usage = information.usage + if not self.connected: + _LOGGER.warning("Connected to %s", self.host) + self.connected = True except eternalegypt.Error: + if self.connected: + _LOGGER.warning("Lost connection to %s", self.host) + self.connected = False self.unread_count = None self.usage = None @@ -90,34 +99,60 @@ async def async_setup(hass, config): return True -async def _setup_lte(hass, lte_config, delay=0): +async def _setup_lte(hass, lte_config): """Set up a Netgear LTE modem.""" import eternalegypt - if delay: - await asyncio.sleep(delay) - host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] websession = hass.data[DATA_KEY].websession - modem = eternalegypt.Modem(hostname=host, websession=websession) - try: - await modem.login(password=password) - except eternalegypt.Error: - delay = max(15, min(2*delay, 300)) - _LOGGER.warning("Retrying %s in %d seconds", host, delay) - hass.loop.create_task(_setup_lte(hass, lte_config, delay)) - return + modem_data = ModemData(host, modem) - modem_data = ModemData(modem) + try: + await _login(hass, modem_data, password) + except eternalegypt.Error: + retry_task = hass.loop.create_task( + _retry_login(hass, modem_data, password)) + + @callback + def cleanup_retry(event): + """Clean up retry task resources.""" + if not retry_task.done(): + retry_task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + + +async def _login(hass, modem_data, password): + """Log in and complete setup.""" + await modem_data.modem.login(password=password) await modem_data.async_update() - hass.data[DATA_KEY].modem_data[host] = modem_data + hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data async def cleanup(event): """Clean up resources.""" - await modem.logout() + await modem_data.modem.logout() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + +async def _retry_login(hass, modem_data, password): + """Sleep and retry setup.""" + import eternalegypt + + _LOGGER.warning( + "Could not connect to %s. Will keep trying.", modem_data.host) + + modem_data.connected = False + delay = 15 + + while not modem_data.connected: + await asyncio.sleep(delay) + + try: + await _login(hass, modem_data, password) + except eternalegypt.Error: + delay = min(2*delay, 300) diff --git a/homeassistant/components/notify/discord.py b/homeassistant/components/notify/discord.py index 8bd4e27155d..e9f0d5cec28 100644 --- a/homeassistant/components/notify/discord.py +++ b/homeassistant/components/notify/discord.py @@ -41,6 +41,8 @@ class DiscordNotificationService(BaseNotificationService): async def async_send_message(self, message, **kwargs): """Login to Discord, send message to channel(s) and log out.""" import discord + + discord.VoiceClient.warn_nacl = False discord_bot = discord.Client(loop=self.hass.loop) if ATTR_TARGET not in kwargs: @@ -53,6 +55,7 @@ class DiscordNotificationService(BaseNotificationService): """Send the messages when the bot is ready.""" try: data = kwargs.get(ATTR_DATA) + images = None if data: images = data.get(ATTR_IMAGES) for channelid in kwargs[ATTR_TARGET]: diff --git a/homeassistant/components/notify/tplink_lte.py b/homeassistant/components/notify/tplink_lte.py new file mode 100644 index 00000000000..9bb80e2591c --- /dev/null +++ b/homeassistant/components/notify/tplink_lte.py @@ -0,0 +1,50 @@ +"""TP-Link LTE platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.tplink_lte/ +""" + +import logging + +import attr + +from homeassistant.components.notify import ( + ATTR_TARGET, BaseNotificationService) + +from ..tplink_lte import DATA_KEY + +DEPENDENCIES = ['tplink_lte'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the notification service.""" + if discovery_info is None: + return + return TplinkNotifyService(hass, discovery_info) + + +@attr.s +class TplinkNotifyService(BaseNotificationService): + """Implementation of a notification service.""" + + hass = attr.ib() + config = attr.ib() + + async def async_send_message(self, message="", **kwargs): + """Send a message to a user.""" + import tp_connected + modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + if not modem_data: + _LOGGER.error("No modem available") + return + + phone = self.config[ATTR_TARGET] + targets = kwargs.get(ATTR_TARGET, phone) + if targets and message: + for target in targets: + try: + await modem_data.modem.sms(target, message) + except tp_connected.Error: + _LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 1f4417e07b5..eac20c62797 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -4,31 +4,50 @@ Jabber (XMPP) notification service. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.xmpp/ """ +from concurrent.futures import TimeoutError as FutTimeoutError import logging +import mimetypes +import pathlib +import random +import string +import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) from homeassistant.const import ( - CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT, CONF_ROOM, CONF_RESOURCE) + CONF_PASSWORD, CONF_RECIPIENT, CONF_RESOURCE, CONF_ROOM, CONF_SENDER) +import homeassistant.helpers.config_validation as cv +import homeassistant.helpers.template as template_helper -REQUIREMENTS = ['slixmpp==1.4.0'] +REQUIREMENTS = ['slixmpp==1.4.1'] _LOGGER = logging.getLogger(__name__) +ATTR_DATA = 'data' +ATTR_PATH = 'path' +ATTR_PATH_TEMPLATE = 'path_template' +ATTR_TIMEOUT = 'timeout' +ATTR_URL = 'url' +ATTR_URL_TEMPLATE = 'url_template' +ATTR_VERIFY = 'verify' + CONF_TLS = 'tls' CONF_VERIFY = 'verify' +DEFAULT_CONTENT_TYPE = 'application/octet-stream' +DEFAULT_RESOURCE = 'home-assistant' +XEP_0363_TIMEOUT = 10 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_SENDER): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_RECIPIENT): cv.string, + vol.Optional(CONF_RESOURCE, default=DEFAULT_RESOURCE): cv.string, + vol.Optional(CONF_ROOM, default=''): cv.string, vol.Optional(CONF_TLS, default=True): cv.boolean, vol.Optional(CONF_VERIFY, default=True): cv.boolean, - vol.Optional(CONF_ROOM, default=''): cv.string, - vol.Optional(CONF_RESOURCE, default="home-assistant"): cv.string, }) @@ -38,16 +57,16 @@ async def async_get_service(hass, config, discovery_info=None): config.get(CONF_SENDER), config.get(CONF_RESOURCE), config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT), config.get(CONF_TLS), config.get(CONF_VERIFY), - config.get(CONF_ROOM), hass.loop) + config.get(CONF_ROOM), hass) class XmppNotificationService(BaseNotificationService): """Implement the notification service for Jabber (XMPP).""" def __init__(self, sender, resource, password, - recipient, tls, verify, room, loop): + recipient, tls, verify, room, hass): """Initialize the service.""" - self._loop = loop + self._hass = hass self._sender = sender self._resource = resource self._password = password @@ -59,18 +78,26 @@ class XmppNotificationService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = '{}: {}'.format(title, message) if title else message + text = '{}: {}'.format(title, message) if title else message + data = kwargs.get(ATTR_DATA) + timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None await async_send_message( '{}/{}'.format(self._sender, self._resource), self._password, self._recipient, self._tls, - self._verify, self._room, self._loop, data) + self._verify, self._room, self._hass, text, + timeout, data) -async def async_send_message(sender, password, recipient, use_tls, - verify_certificate, room, loop, message): +async def async_send_message( + sender, password, recipient, use_tls, verify_certificate, room, hass, + message, timeout=None, data=None): """Send a message over XMPP.""" import slixmpp + from slixmpp.exceptions import IqError, IqTimeout, XMPPError + from slixmpp.xmlstream.xmlstream import NotConnectedError + from slixmpp.plugins.xep_0363.http_upload import FileTooBig, \ + FileUploadError, UploadServiceNotFound class SendNotificationBot(slixmpp.ClientXMPP): """Service for sending Jabber (XMPP) messages.""" @@ -79,8 +106,7 @@ async def async_send_message(sender, password, recipient, use_tls, """Initialize the Jabber Bot.""" super().__init__(sender, password) - # need hass.loop!! - self.loop = loop + self.loop = hass.loop self.force_starttls = use_tls self.use_ipv6 = False @@ -93,29 +119,222 @@ async def async_send_message(sender, password, recipient, use_tls, if not verify_certificate: self.add_event_handler('ssl_invalid_cert', self.discard_ssl_invalid_cert) + if data: + # Init XEPs for image sending + self.register_plugin('xep_0030') # OOB dep + self.register_plugin('xep_0066') # Out of Band Data + self.register_plugin('xep_0071') # XHTML IM + self.register_plugin('xep_0128') # Service Discovery + self.register_plugin('xep_0363') # HTTP upload self.connect(force_starttls=self.force_starttls, use_ssl=False) - def start(self, event): + async def start(self, event): """Start the communication and sends the message.""" - self.get_roster() - self.send_presence() - if room: - _LOGGER.debug("Joining room %s", room) - self.plugin['xep_0045'].join_muc(room, sender, wait=True) - self.send_message(mto=room, mbody=message, mtype='groupchat') - else: - self.send_message(mto=recipient, mbody=message, mtype='chat') + # Sending image and message independently from each other + if data: + await self.send_file(timeout=timeout) + if message: + self.send_text_message() + self.disconnect(wait=True) + async def send_file(self, timeout=None): + """Send file via XMPP. + + Send XMPP file message using OOB (XEP_0066) and + HTTP Upload (XEP_0363) + """ + if room: + self.plugin['xep_0045'].join_muc(room, sender, wait=True) + + try: + # Uploading with XEP_0363 + _LOGGER.debug("Timeout set to %ss", timeout) + url = await self.upload_file(timeout=timeout) + + _LOGGER.info("Upload success") + if room: + _LOGGER.info("Sending file to %s", room) + message = self.Message(sto=room, stype='groupchat') + else: + _LOGGER.info("Sending file to %s", recipient) + message = self.Message(sto=recipient, stype='chat') + + message['body'] = url + # pylint: disable=invalid-sequence-index + message['oob']['url'] = url + try: + message.send() + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Could not send image message %s", ex) + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Upload error, could not send message %s", ex) + except NotConnectedError as ex: + _LOGGER.error("Connection error %s", ex) + except FileTooBig as ex: + _LOGGER.error( + "File too big for server, could not upload file %s", ex) + except UploadServiceNotFound as ex: + _LOGGER.error("UploadServiceNotFound: " + " could not upload file %s", ex) + except FileUploadError as ex: + _LOGGER.error("FileUploadError, could not upload file %s", ex) + except requests.exceptions.SSLError as ex: + _LOGGER.error("Cannot establish SSL connection %s", ex) + except requests.exceptions.ConnectionError as ex: + _LOGGER.error("Cannot connect to server %s", ex) + except (FileNotFoundError, + PermissionError, + IsADirectoryError, + TimeoutError) as ex: + _LOGGER.error("Error reading file %s", ex) + except FutTimeoutError as ex: + _LOGGER.error("The server did not respond in time, %s", ex) + + async def upload_file(self, timeout=None): + """Upload file to Jabber server and return new URL. + + upload a file with Jabber XEP_0363 from a remote URL or a local + file path and return a URL of that file. + """ + if data.get(ATTR_URL_TEMPLATE): + _LOGGER.debug( + "Got url template: %s", data[ATTR_URL_TEMPLATE]) + templ = template_helper.Template( + data[ATTR_URL_TEMPLATE], hass) + get_url = template_helper.render_complex(templ, None) + url = await self.upload_file_from_url( + get_url, timeout=timeout) + elif data.get(ATTR_URL): + url = await self.upload_file_from_url( + data[ATTR_URL], timeout=timeout) + elif data.get(ATTR_PATH_TEMPLATE): + _LOGGER.debug( + "Got path template: %s", data[ATTR_PATH_TEMPLATE]) + templ = template_helper.Template( + data[ATTR_PATH_TEMPLATE], hass) + get_path = template_helper.render_complex(templ, None) + url = await self.upload_file_from_path( + get_path, timeout=timeout) + elif data.get(ATTR_PATH): + url = await self.upload_file_from_path( + data[ATTR_PATH], timeout=timeout) + else: + url = None + + if url is None: + _LOGGER.error("No path or URL found for file") + raise FileUploadError("Could not upload file") + + return url + + async def upload_file_from_url(self, url, timeout=None): + """Upload a file from a URL. Returns a URL. + + uploaded via XEP_0363 and HTTP and returns the resulting URL + """ + _LOGGER.info("Getting file from %s", url) + + def get_url(url): + """Return result for GET request to url.""" + return requests.get( + url, verify=data.get(ATTR_VERIFY, True), timeout=timeout) + result = await hass.async_add_executor_job(get_url, url) + + if result.status_code >= 400: + _LOGGER.error("Could not load file from %s", url) + return None + + filesize = len(result.content) + + # we need a file extension, the upload server needs a + # filename, if none is provided, through the path we guess + # the extension + # also setting random filename for privacy + if data.get(ATTR_PATH): + # using given path as base for new filename. Don't guess type + filename = self.get_random_filename(data.get(ATTR_PATH)) + else: + extension = mimetypes.guess_extension( + result.headers['Content-Type']) or ".unknown" + _LOGGER.debug("Got %s extension", extension) + filename = self.get_random_filename(None, extension=extension) + + _LOGGER.info("Uploading file from URL, %s", filename) + + url = await self['xep_0363'].upload_file( + filename, size=filesize, input_file=result.content, + content_type=result.headers['Content-Type'], timeout=timeout) + + return url + + async def upload_file_from_path(self, path, timeout=None): + """Upload a file from a local file path via XEP_0363.""" + _LOGGER.info('Uploading file from path, %s ...', path) + + if not hass.config.is_allowed_path(path): + raise PermissionError( + "Could not access file. Not in whitelist.") + + with open(path, 'rb') as upfile: + _LOGGER.debug("Reading file %s", path) + input_file = upfile.read() + filesize = len(input_file) + _LOGGER.debug("Filesize is %s bytes", filesize) + + content_type = mimetypes.guess_type(path)[0] + if content_type is None: + content_type = DEFAULT_CONTENT_TYPE + _LOGGER.debug("Content type is %s", content_type) + + # set random filename for privacy + filename = self.get_random_filename(data.get(ATTR_PATH)) + _LOGGER.debug("Uploading file with random filename %s", filename) + + url = await self['xep_0363'].upload_file( + filename, size=filesize, input_file=input_file, + content_type=content_type, timeout=timeout) + + return url + + def send_text_message(self): + """Send a text only message to a room or a recipient.""" + try: + if room: + _LOGGER.debug("Joining room %s", room) + self.plugin['xep_0045'].join_muc(room, sender, wait=True) + self.send_message( + mto=room, mbody=message, mtype='groupchat') + else: + _LOGGER.debug("Sending message to %s", recipient) + self.send_message( + mto=recipient, mbody=message, mtype='chat') + except (IqError, IqTimeout, XMPPError) as ex: + _LOGGER.error("Could not send text message %s", ex) + except NotConnectedError as ex: + _LOGGER.error("Connection error %s", ex) + + # pylint: disable=no-self-use + def get_random_filename(self, filename, extension=None): + """Return a random filename, leaving the extension intact.""" + if extension is None: + path = pathlib.Path(filename) + if path.suffix: + extension = ''.join(path.suffixes) + else: + extension = ".txt" + return ''.join(random.choice(string.ascii_letters) + for i in range(10)) + extension + def disconnect_on_login_fail(self, event): """Disconnect from the server if credentials are invalid.""" - _LOGGER.warning('Login failed') + _LOGGER.warning("Login failed") self.disconnect() @staticmethod def discard_ssl_invalid_cert(event): """Do nothing if ssl certificate is invalid.""" - _LOGGER.info('Ignoring invalid ssl certificate as requested') + _LOGGER.info("Ignoring invalid SSL certificate as requested") SendNotificationBot() diff --git a/homeassistant/components/openuv/.translations/es.json b/homeassistant/components/openuv/.translations/es.json new file mode 100644 index 00000000000..03118f00ea6 --- /dev/null +++ b/homeassistant/components/openuv/.translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Coordenadas ya registradas", + "invalid_api_key": "Clave API inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API de OpenUV", + "elevation": "Elevaci\u00f3n", + "latitude": "Latitud", + "longitude": "Longitud" + }, + "title": "Completa tu informaci\u00f3n" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json new file mode 100644 index 00000000000..a34077a0a83 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + }, + "step": { + "user": { + "description": "Are you sure you want to set up OwnTracks?", + "title": "Set up OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py new file mode 100644 index 00000000000..a5da7f5fc48 --- /dev/null +++ b/homeassistant/components/owntracks/__init__.py @@ -0,0 +1,219 @@ +"""Component for OwnTracks.""" +from collections import defaultdict +import json +import logging +import re + +from aiohttp.web import json_response +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components import mqtt +from homeassistant.setup import async_when_setup +import homeassistant.helpers.config_validation as cv + +from .config_flow import CONF_SECRET + +DOMAIN = "owntracks" +REQUIREMENTS = ['libnacl==1.6.1'] +DEPENDENCIES = ['device_tracker', 'webhook'] + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' +CONF_WAYPOINT_IMPORT = 'waypoints' +CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist' +CONF_MQTT_TOPIC = 'mqtt_topic' +CONF_REGION_MAPPING = 'region_mapping' +CONF_EVENTS_ONLY = 'events_only' +BEACON_DEV_ID = 'beacon' + +DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default={}): { + vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), + vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean, + vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC): + mqtt.valid_subscribe_topic, + vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All( + cv.ensure_list, [cv.string]), + vol.Optional(CONF_SECRET): vol.Any( + vol.Schema({vol.Optional(cv.string): cv.string}), + cv.string), + vol.Optional(CONF_REGION_MAPPING, default={}): dict, + vol.Optional(CONF_WEBHOOK_ID): cv.string, + } +}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass, config): + """Initialize OwnTracks component.""" + hass.data[DOMAIN] = { + 'config': config[DOMAIN] + } + if not hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={} + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up OwnTracks entry.""" + config = hass.data[DOMAIN]['config'] + max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) + waypoint_import = config.get(CONF_WAYPOINT_IMPORT) + waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST) + secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET] + region_mapping = config.get(CONF_REGION_MAPPING) + events_only = config.get(CONF_EVENTS_ONLY) + mqtt_topic = config.get(CONF_MQTT_TOPIC) + + context = OwnTracksContext(hass, secret, max_gps_accuracy, + waypoint_import, waypoint_whitelist, + region_mapping, events_only, mqtt_topic) + + webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID] + + hass.data[DOMAIN]['context'] = context + + async_when_setup(hass, 'mqtt', async_connect_mqtt) + + hass.components.webhook.async_register( + DOMAIN, 'OwnTracks', webhook_id, handle_webhook) + + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'device_tracker')) + + return True + + +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): + """Handle incoming OwnTracks message.""" + try: + message = json.loads(payload) + except ValueError: + # If invalid JSON + _LOGGER.error("Unable to parse payload as JSON: %s", payload) + return + + message['topic'] = topic + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + + await hass.components.mqtt.async_subscribe( + context.mqtt_topic, async_handle_mqtt_message, 1) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + context = hass.data[DOMAIN]['context'] + message = await request.json() + + # Android doesn't populate topic + if 'topic' not in message: + headers = request.headers + user = headers.get('X-Limit-U') + device = headers.get('X-Limit-D', user) + + if user is None: + _LOGGER.warning('Set a username in Connection -> Identification') + return json_response( + {'error': 'You need to supply username.'}, + status=400 + ) + + topic_base = re.sub('/#$', '', context.mqtt_topic) + message['topic'] = '{}/{}/{}'.format(topic_base, user, device) + + hass.helpers.dispatcher.async_dispatcher_send( + DOMAIN, hass, context, message) + return json_response([]) + + +class OwnTracksContext: + """Hold the current OwnTracks context.""" + + def __init__(self, hass, secret, max_gps_accuracy, import_waypoints, + waypoint_whitelist, region_mapping, events_only, mqtt_topic): + """Initialize an OwnTracks context.""" + self.hass = hass + self.secret = secret + self.max_gps_accuracy = max_gps_accuracy + self.mobile_beacons_active = defaultdict(set) + self.regions_entered = defaultdict(list) + self.import_waypoints = import_waypoints + self.waypoint_whitelist = waypoint_whitelist + self.region_mapping = region_mapping + self.events_only = events_only + self.mqtt_topic = mqtt_topic + + @callback + def async_valid_accuracy(self, message): + """Check if we should ignore this message.""" + acc = message.get('acc') + + if acc is None: + return False + + try: + acc = float(acc) + except ValueError: + return False + + if acc == 0: + _LOGGER.warning( + "Ignoring %s update because GPS accuracy is zero: %s", + message['_type'], message) + return False + + if self.max_gps_accuracy is not None and \ + acc > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + message['_type'], self.max_gps_accuracy, + message) + return False + + return True + + async def async_see(self, **data): + """Send a see message to the device tracker.""" + await self.hass.components.device_tracker.async_see(**data) + + async def async_see_beacons(self, hass, dev_id, kwargs_param): + """Set active beacons to the current location.""" + kwargs = kwargs_param.copy() + + # Mobile beacons should always be set to the location of the + # tracking device. I get the device state and make the necessary + # changes to kwargs. + device_tracker_state = hass.states.get( + "device_tracker.{}".format(dev_id)) + + if device_tracker_state is not None: + acc = device_tracker_state.attributes.get("gps_accuracy") + lat = device_tracker_state.attributes.get("latitude") + lon = device_tracker_state.attributes.get("longitude") + kwargs['gps_accuracy'] = acc + kwargs['gps'] = (lat, lon) + + # the battery state applies to the tracking device, not the beacon + # kwargs location is the beacon's configured lat/lon + kwargs.pop('battery', None) + for beacon in self.mobile_beacons_active[dev_id]: + kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon) + kwargs['host_name'] = beacon + await self.async_see(**kwargs) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py new file mode 100644 index 00000000000..88362946428 --- /dev/null +++ b/homeassistant/components/owntracks/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for OwnTracks.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.auth.util import generate_secret + +CONF_SECRET = 'secret' + + +def supports_encryption(): + """Test if we support encryption.""" + try: + # pylint: disable=unused-variable + import libnacl # noqa + return True + except OSError: + return False + + +@config_entries.HANDLERS.register('owntracks') +class OwnTracksFlow(config_entries.ConfigFlow): + """Set up OwnTracks.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a user initiated set up flow to create OwnTracks webhook.""" + if self._async_current_entries(): + return self.async_abort(reason='one_instance_allowed') + + if user_input is None: + return self.async_show_form( + step_id='user', + ) + + webhook_id = self.hass.components.webhook.async_generate_id() + webhook_url = \ + self.hass.components.webhook.async_generate_url(webhook_id) + + secret = generate_secret(16) + + if supports_encryption(): + secret_desc = ( + "The encryption key is {secret} " + "(on Android under preferences -> advanced)") + else: + secret_desc = ( + "Encryption is not supported because libsodium is not " + "installed.") + + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + }, + description_placeholders={ + 'secret': secret_desc, + 'webhook_url': webhook_url, + 'android_url': + 'https://play.google.com/store/apps/details?' + 'id=org.owntracks.android', + 'ios_url': + 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8', + 'docs_url': + 'https://www.home-assistant.io/components/owntracks/' + } + ) + + async def async_step_import(self, user_input): + """Import a config flow from configuration.""" + webhook_id = self.hass.components.webhook.async_generate_id() + secret = generate_secret(16) + return self.async_create_entry( + title="OwnTracks", + data={ + CONF_WEBHOOK_ID: webhook_id, + CONF_SECRET: secret + } + ) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json new file mode 100644 index 00000000000..fcf7305d714 --- /dev/null +++ b/homeassistant/components/owntracks/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "title": "OwnTracks", + "step": { + "user": { + "title": "Set up OwnTracks", + "description": "Are you sure you want to set up OwnTracks?" + } + }, + "abort": { + "one_instance_allowed": "Only a single instance is necessary." + }, + "create_entry": { + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + } + } +} diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json new file mode 100644 index 00000000000..6298b29f268 --- /dev/null +++ b/homeassistant/components/point/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s podeu configurar un compte de Point.", + "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.", + "external_setup": "Point s'ha configurat correctament des d'un altre lloc.", + "no_flows": "Necessiteu configurar Point abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Minut per als vostres dispositiu/s Point." + }, + "error": { + "follow_link": "Si us plau seguiu l'enlla\u00e7 i autentiqueu-vos abans de pr\u00e9mer Enviar", + "no_token": "No s'ha autenticat amb Minut" + }, + "step": { + "auth": { + "description": "Aneu a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al vostre compte de Minut, despr\u00e9s torneu i premeu Enviar (a sota). \n\n[Enlla\u00e7]({authorization_url})", + "title": "Autenticar Point" + }, + "user": { + "data": { + "flow_impl": "Prove\u00efdor" + }, + "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Point.", + "title": "Prove\u00efdor d'autenticaci\u00f3" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json new file mode 100644 index 00000000000..705ac59b98d --- /dev/null +++ b/homeassistant/components/point/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure a Point account.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "authorize_url_timeout": "Timeout generating authorize url.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "follow_link": "Please follow the link and authenticate before pressing Submit", + "no_token": "Not authenticated with Minut" + }, + "step": { + "auth": { + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})", + "title": "Authenticate Point" + }, + "user": { + "data": { + "flow_impl": "Provider" + }, + "description": "Pick via which authentication provider you want to authenticate with Point.", + "title": "Authentication Provider" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/it.json b/homeassistant/components/point/.translations/it.json new file mode 100644 index 00000000000..00e2cb02358 --- /dev/null +++ b/homeassistant/components/point/.translations/it.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Provider" + }, + "title": "Provider di autenticazione" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json new file mode 100644 index 00000000000..fcc9a92bd5e --- /dev/null +++ b/homeassistant/components/point/.translations/ko.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Point \uacc4\uc815 \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "external_setup": "Point \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.", + "no_flows": "Point \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Point \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/point/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "Point \uc7a5\uce58\ub294 Minut \ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", + "no_token": "Minut \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4" + }, + "step": { + "auth": { + "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud55c \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n [\ub9c1\ud06c] ( {authorization_url} )", + "title": "Point \uc778\uc99d" + }, + "user": { + "data": { + "flow_impl": "\uacf5\uae09\uc790" + }, + "description": "Point\ub85c \uc778\uc99d\ud558\ub824\ub294 \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "title": "\uc778\uc99d \uacf5\uae09\uc790" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/lb.json b/homeassistant/components/point/.translations/lb.json new file mode 100644 index 00000000000..571f4617215 --- /dev/null +++ b/homeassistant/components/point/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Point Kont konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", + "external_setup": "Point gouf vun engem anere Floss erfollegr\u00e4ich konfigur\u00e9iert.", + "no_flows": "Dir musst Point konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mat Minut authentifiz\u00e9iert fir \u00e4r Point Apparater" + }, + "error": { + "follow_link": "Follegt w.e.g dem Link an authentifiz\u00e9iert iech ier de op Ofsch\u00e9cken dr\u00e9ckt", + "no_token": "Net mat Minut authentifiz\u00e9iert" + }, + "step": { + "auth": { + "description": "Follegt dem Link \u00ebnnendr\u00ebnner an accept\u00e9iert den Acc\u00e8s zu \u00e4rem Minut Kont , dann kommt zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n\n[Link]({authorization_url})", + "title": "Point authentifiz\u00e9ieren" + }, + "user": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Point verbanne soll.", + "title": "Authentifikatioun Ubidder" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json new file mode 100644 index 00000000000..1257e1a7f01 --- /dev/null +++ b/homeassistant/components/point/.translations/ru.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", + "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Point \u043f\u0435\u0440\u0435\u0434 \u0442\u0435\u043c, \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)." + }, + "error": { + "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0443 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c", + "no_token": "\u041d\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434 \u0432 Minut" + }, + "step": { + "auth": { + "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c.", + "title": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point" + }, + "user": { + "data": { + "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u043a\u043e\u0433\u043e \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0432\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Point.", + "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/sv.json b/homeassistant/components/point/.translations/sv.json new file mode 100644 index 00000000000..6464434eda4 --- /dev/null +++ b/homeassistant/components/point/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Autentiseringsleverant\u00f6r", + "description": "V\u00e4lj den autentiseringsleverant\u00f6r som du vill autentisera med mot Point.", + "data": { + "flow_impl": "Leverant\u00f6r" + } + }, + "auth": { + "title": "Autentisera Point", + "description": "F\u00f6lj l\u00e4nken nedan och klicka p\u00e5 Accept f\u00f6r att tilll\u00e5ta tillg\u00e5ng till ditt Minut konto, kom d\u00f6refter tillbaka hit och kicka p\u00e5 Submit nedan.\n\n[L\u00e4nk]({authorization_url})" + } + }, + "create_entry": { + "default": "Autentiserad med Minut f\u00f6r era Point enheter." + }, + "error": { + "no_token": "Inte autentiserad hos Minut", + "follow_link": "F\u00f6lj l\u00e4nken och autentisera innan du kickar på Submit" + }, + "abort": { + "already_setup": "Du kan endast konfigurera ett Point-konto.", + "external_setup": "Point har lyckats konfigureras fr\u00e5n ett annat fl\u00f6de.", + "no_flows": "Du m\u00e5ste konfigurera Nest innan du kan autentisera med det. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress.", + "authorize_url_fail": "Ok\u00e4nt fel vid generering av autentisieringsadress." + } + } +} + \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json new file mode 100644 index 00000000000..7d88bfeec42 --- /dev/null +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Point \u8fdb\u884c\u6388\u6743\u3002", + "title": "\u6388\u6743\u63d0\u4f9b\u8005" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py new file mode 100644 index 00000000000..36215da7893 --- /dev/null +++ b/homeassistant/components/point/__init__.py @@ -0,0 +1,306 @@ +""" +Support for Minut Point. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/point/ +""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp + +from . import config_flow # noqa pylint_disable=unused-import +from .const import ( + CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, + SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) + +REQUIREMENTS = ['pypoint==1.0.6'] +DEPENDENCIES = ['webhook'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + }) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Minut Point component.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + config_flow.register_flow_implementation( + hass, DOMAIN, conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET]) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + )) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Set up Point from a config entry.""" + from pypoint import PointSession + + def token_saver(token): + _LOGGER.debug('Saving updated token') + entry.data[CONF_TOKEN] = token + hass.config_entries.async_update_entry(entry, data={**entry.data}) + + # Force token update. + entry.data[CONF_TOKEN]['expires_in'] = -1 + session = PointSession( + entry.data['refresh_args']['client_id'], + token=entry.data[CONF_TOKEN], + auto_refresh_kwargs=entry.data['refresh_args'], + token_saver=token_saver, + ) + + if not session.is_authorized: + _LOGGER.error('Authentication Error') + return False + + await async_setup_webhook(hass, entry, session) + client = MinutPointClient(hass, entry, session) + hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) + await client.update() + + return True + + +async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, + session): + """Set up a webhook to handle binary sensor events.""" + if CONF_WEBHOOK_ID not in entry.data: + entry.data[CONF_WEBHOOK_ID] = \ + hass.components.webhook.async_generate_id() + entry.data[CONF_WEBHOOK_URL] = \ + hass.components.webhook.async_generate_url( + entry.data[CONF_WEBHOOK_ID]) + _LOGGER.info('Registering new webhook at: %s', + entry.data[CONF_WEBHOOK_URL]) + hass.config_entries.async_update_entry( + entry, data={ + **entry.data, + }) + session.update_webhook(entry.data[CONF_WEBHOOK_URL], + entry.data[CONF_WEBHOOK_ID]) + + hass.components.webhook.async_register( + DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Unload a config entry.""" + hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID]) + client = hass.data[DOMAIN].pop(entry.entry_id) + client.remove_webhook() + + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + for component in ('binary_sensor', 'sensor'): + await hass.config_entries.async_forward_entry_unload( + entry, component) + + return True + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + try: + data = await request.json() + _LOGGER.debug("Webhook %s: %s", webhook_id, data) + except ValueError: + return None + + if isinstance(data, dict): + data['webhook_id'] = webhook_id + async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get('hook_id')) + hass.bus.async_fire(EVENT_RECEIVED, data) + + +class MinutPointClient(): + """Get the latest data and update the states.""" + + def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, + session): + """Initialize the Minut data object.""" + self._known_devices = [] + self._hass = hass + self._config_entry = config_entry + self._is_available = True + self._client = session + + async_track_time_interval(self._hass, self.update, SCAN_INTERVAL) + + async def update(self, *args): + """Periodically poll the cloud for current state.""" + await self._sync() + + async def _sync(self): + """Update local list of devices.""" + if not self._client.update() and self._is_available: + self._is_available = False + _LOGGER.warning("Device is unavailable") + return + + self._is_available = True + for device in self._client.devices: + if device.device_id not in self._known_devices: + # A way to communicate the device_id to entry_setup, + # can this be done nicer? + self._config_entry.data[NEW_DEVICE] = device.device_id + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor') + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'binary_sensor') + self._known_devices.append(device.device_id) + del self._config_entry.data[NEW_DEVICE] + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) + + def device(self, device_id): + """Return device representation.""" + return self._client.device(device_id) + + def is_available(self, device_id): + """Return device availability.""" + return device_id in self._client.device_ids + + def remove_webhook(self): + """Remove the session webhook.""" + return self._client.remove_webhook() + + +class MinutPointEntity(Entity): + """Base Entity used by the sensors.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + self._async_unsub_dispatcher_connect = None + self._client = point_client + self._id = device_id + self._name = self.device.name + self._device_class = device_class + self._updated = utc_from_timestamp(0) + self._value = None + + def __str__(self): + """Return string representation of device.""" + return "MinutPoint {}".format(self.name) + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + _LOGGER.debug('Created device %s', self) + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + self._update_callback() + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + pass + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def device_state_attributes(self): + """Return status of device.""" + attrs = self.device.device_status + attrs['last_heard_from'] = \ + as_local(self.last_update).strftime("%Y-%m-%d %H:%M:%S") + return attrs + + @property + def device_info(self): + """Return a device description for device registry.""" + device = self.device.device + return { + 'connections': {('mac', device['device_mac'])}, + 'identifieres': device['device_id'], + 'manufacturer': 'Minut', + 'model': 'Point v{}'.format(device['hardware_version']), + 'name': device['description'], + 'sw_version': device['firmware']['installed'], + } + + @property + def name(self): + """Return the display name of this device.""" + return "{} {}".format(self._name, self.device_class.capitalize()) + + @property + def is_updated(self): + """Return true if sensor have been updated.""" + return self.last_update > self._updated + + @property + def last_update(self): + """Return the last_update time for the device.""" + last_update = parse_datetime(self.device.last_update) + return last_update + + @property + def should_poll(self): + """No polling needed for point.""" + return False + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return 'point.{}-{}'.format(self._id, self.device_class) + + @property + def value(self): + """Return the sensor value.""" + return self._value diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py new file mode 100644 index 00000000000..8cda30c7171 --- /dev/null +++ b/homeassistant/components/point/config_flow.py @@ -0,0 +1,189 @@ +"""Config flow for Minut Point.""" +import asyncio +from collections import OrderedDict +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import callback + +from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN + +AUTH_CALLBACK_PATH = '/api/minut' +AUTH_CALLBACK_NAME = 'api:minut' + +DATA_FLOW_IMPL = 'point_flow_implementation' + +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, domain, client_id, client_secret): + """Register a flow implementation. + + domain: Domain of the component responsible for the implementation. + name: Name of the component. + client_id: Client id. + client_secret: Client secret. + """ + if DATA_FLOW_IMPL not in hass.data: + hass.data[DATA_FLOW_IMPL] = OrderedDict() + + hass.data[DATA_FLOW_IMPL][domain] = { + CLIENT_ID: client_id, + CLIENT_SECRET: client_secret, + } + + +@config_entries.HANDLERS.register('point') +class PointFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNETION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize flow.""" + self.flow_impl = None + + async def async_step_import(self, user_input=None): + """Handle external yaml configuration.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + self.flow_impl = DOMAIN + + return await self.async_step_auth() + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + flows = self.hass.data.get(DATA_FLOW_IMPL, {}) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if not flows: + _LOGGER.debug("no flows") + return self.async_abort(reason='no_flows') + + if len(flows) == 1: + self.flow_impl = list(flows)[0] + return await self.async_step_auth() + + if user_input is not None: + self.flow_impl = user_input['flow_impl'] + return await self.async_step_auth() + + return self.async_show_form( + step_id='user', + data_schema=vol.Schema({ + vol.Required('flow_impl'): + vol.In(list(flows)) + })) + + async def async_step_auth(self, user_input=None): + """Create an entry for auth.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='external_setup') + + errors = {} + + if user_input is not None: + errors['base'] = 'follow_link' + + try: + with async_timeout.timeout(10): + url = await self._get_authorization_url() + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error generating auth url") + return self.async_abort(reason='authorize_url_fail') + + return self.async_show_form( + step_id='auth', + description_placeholders={'authorization_url': url}, + errors=errors, + ) + + async def _get_authorization_url(self): + """Create Minut Point session and get authorization url.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, client_secret=client_secret) + + self.hass.http.register_view(MinutAuthCallbackView()) + + return point_session.get_authorization_url + + async def async_step_code(self, code=None): + """Received code for authentication.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if code is None: + return self.async_abort(reason='no_code') + + _LOGGER.debug("Should close all flows below %s", + self.hass.config_entries.flow.async_progress()) + # Remove notification if no other discovery config entries in progress + + return await self._async_create_session(code) + + async def _async_create_session(self, code): + """Create point session and entries.""" + from pypoint import PointSession + flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] + client_id = flow[CLIENT_ID] + client_secret = flow[CLIENT_SECRET] + point_session = PointSession( + client_id, + client_secret=client_secret, + ) + token = await self.hass.async_add_executor_job( + point_session.get_access_token, code) + _LOGGER.debug("Got new token") + if not point_session.is_authorized: + _LOGGER.error('Authentication Error') + return self.async_abort(reason='auth_error') + + _LOGGER.info('Successfully authenticated Point') + user_email = point_session.user().get('email') or "" + + return self.async_create_entry( + title=user_email, + data={ + 'token': token, + 'refresh_args': { + 'client_id': client_id, + 'client_secret': client_secret + } + }, + ) + + +class MinutAuthCallbackView(HomeAssistantView): + """Minut Authorization Callback View.""" + + requires_auth = False + url = AUTH_CALLBACK_PATH + name = AUTH_CALLBACK_NAME + + @staticmethod + async def get(request): + """Receive authorization code.""" + hass = request.app['hass'] + if 'code' in request.query: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': 'code'}, + data=request.query['code'], + )) + return "OK!" diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py new file mode 100644 index 00000000000..4ef21b57cd9 --- /dev/null +++ b/homeassistant/components/point/const.py @@ -0,0 +1,15 @@ +"""Define constants for the Point component.""" +from datetime import timedelta + +DOMAIN = 'point' +CLIENT_ID = 'client_id' +CLIENT_SECRET = 'client_secret' + + +SCAN_INTERVAL = timedelta(minutes=1) + +CONF_WEBHOOK_URL = 'webhook_url' +EVENT_RECEIVED = 'point_webhook_received' +SIGNAL_UPDATE_ENTITY = 'point_update' +SIGNAL_WEBHOOK = 'point_webhook' +NEW_DEVICE = 'new_device' diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json new file mode 100644 index 00000000000..642a61a5f9d --- /dev/null +++ b/homeassistant/components/point/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "Minut Point", + "step": { + "user": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Point.", + "data": { + "flow_impl": "Provider" + } + }, + "auth": { + "title": "Authenticate Point", + "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})" + } + }, + "create_entry": { + "default": "Successfully authenticated with Minut for your Point device(s)" + }, + "error": { + "no_token": "Not authenticated with Minut", + "follow_link": "Please follow the link and authenticate before pressing Submit" + }, + "abort": { + "already_setup": "You can only configure a Point account.", + "external_setup": "Point successfully configured from another flow.", + "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index ee4b88d4d9b..dc868530f88 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -138,6 +138,15 @@ class PrometheusMetrics: value = state_helper.state_as_number(state) metric.labels(**self._labels(state)).set(value) + def _handle_input_boolean(self, state): + metric = self._metric( + 'input_boolean_state', + self.prometheus_client.Gauge, + 'State of the input boolean (0/1)', + ) + value = state_helper.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + def _handle_device_tracker(self, state): metric = self._metric( 'device_tracker_state', diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json new file mode 100644 index 00000000000..7a1459cff6b --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Aquest compte ja est\u00e0 registrat", + "invalid_credentials": "Credencials inv\u00e0lides" + }, + "step": { + "user": { + "data": { + "ip_address": "Nom de l'amfitri\u00f3 o adre\u00e7a IP", + "password": "Contrasenya", + "port": "Port" + }, + "title": "Introdu\u00efu la vostra informaci\u00f3" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json new file mode 100644 index 00000000000..c262fa5a652 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto bereits registriert", + "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostname oder IP-Adresse", + "password": "Passwort", + "port": "Port" + }, + "title": "Informationen eingeben" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/en.json b/homeassistant/components/rainmachine/.translations/en.json new file mode 100644 index 00000000000..54b67066f2b --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostname or IP Address", + "password": "Password", + "port": "Port" + }, + "title": "Fill in your information" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ko.json b/homeassistant/components/rainmachine/.translations/ko.json new file mode 100644 index 00000000000..0885c7e9e66 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "ip_address": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c", + "password": "\ube44\ubc00\ubc88\ud638", + "port": "\ud3ec\ud2b8" + }, + "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/lb.json b/homeassistant/components/rainmachine/.translations/lb.json new file mode 100644 index 00000000000..4456b105fbc --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto ass scho registr\u00e9iert", + "invalid_credentials": "Ong\u00eblteg Login Informatioune" + }, + "step": { + "user": { + "data": { + "ip_address": "Host Numm oder IP Adresse", + "password": "Passwuert", + "port": "Port" + }, + "title": "F\u00ebllt \u00e4r Informatiounen aus" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/nl.json b/homeassistant/components/rainmachine/.translations/nl.json new file mode 100644 index 00000000000..2e1e62c683c --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Account bestaat al", + "invalid_credentials": "Ongeldige gebruikersgegevens" + }, + "step": { + "user": { + "data": { + "ip_address": "Hostnaam of IP-adres", + "password": "Wachtwoord", + "port": "Poort" + }, + "title": "Vul uw gegevens in" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json new file mode 100644 index 00000000000..5ec4e5fdc34 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto er allerede registrert", + "invalid_credentials": "Ugyldig legitimasjon" + }, + "step": { + "user": { + "data": { + "ip_address": "Vertsnavn eller IP-adresse", + "password": "Passord", + "port": "Port" + }, + "title": "Fyll ut informasjonen din" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json new file mode 100644 index 00000000000..4a714f18999 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430", + "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435" + }, + "step": { + "user": { + "data": { + "ip_address": "\u0418\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "port": "\u041f\u043e\u0440\u0442" + }, + "title": "\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0432\u043e\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hans.json b/homeassistant/components/rainmachine/.translations/zh-Hans.json new file mode 100644 index 00000000000..7c6f07a7edd --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e10\u6237\u5df2\u6ce8\u518c" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3" + }, + "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json new file mode 100644 index 00000000000..518cc54192f --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", + "invalid_credentials": "\u6191\u8b49\u7121\u6548" + }, + "step": { + "user": { + "data": { + "ip_address": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0" + }, + "title": "\u586b\u5beb\u8cc7\u8a0a" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 9f15c8b373f..928c2ab2027 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -9,25 +9,25 @@ from datetime import timedelta import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, CONF_MONITORED_CONDITIONS, CONF_SWITCHES) -from homeassistant.helpers import ( - aiohttp_client, config_validation as cv, discovery) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -REQUIREMENTS = ['regenmaschine==1.0.2'] +from .config_flow import configured_instances +from .const import DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN + +REQUIREMENTS = ['regenmaschine==1.0.7'] _LOGGER = logging.getLogger(__name__) -DATA_RAINMACHINE = 'data_rainmachine' -DOMAIN = 'rainmachine' - -NOTIFICATION_ID = 'rainmachine_notification' -NOTIFICATION_TITLE = 'RainMachine Component Setup' +DATA_LISTENER = 'listener' PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) @@ -39,8 +39,6 @@ CONF_ZONE_RUN_TIME = 'zone_run_time' DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' DEFAULT_ICON = 'mdi:water' -DEFAULT_PORT = 8080 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 @@ -120,48 +118,73 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the RainMachine component.""" - from regenmaschine import Client - from regenmaschine.errors import RequestError + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True conf = config[DOMAIN] - ip_address = conf[CONF_IP_ADDRESS] - password = conf[CONF_PASSWORD] - port = conf[CONF_PORT] - ssl = conf[CONF_SSL] + + if conf[CONF_IP_ADDRESS] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up RainMachine as config entry.""" + from regenmaschine import login + from regenmaschine.errors import RainMachineError + + ip_address = config_entry.data[CONF_IP_ADDRESS] + password = config_entry.data[CONF_PASSWORD] + port = config_entry.data[CONF_PORT] + ssl = config_entry.data.get(CONF_SSL, DEFAULT_SSL) + + websession = aiohttp_client.async_get_clientsession(hass) try: - websession = aiohttp_client.async_get_clientsession(hass) - client = Client(ip_address, websession, port=port, ssl=ssl) - await client.authenticate(password) - rainmachine = RainMachine(client) + client = await login( + ip_address, password, websession, port=port, ssl=ssl) + rainmachine = RainMachine( + client, + config_entry.data.get(CONF_BINARY_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)), + config_entry.data.get(CONF_SENSORS, {}).get( + CONF_MONITORED_CONDITIONS, list(SENSORS)), + config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) + ) await rainmachine.async_update() - hass.data[DATA_RAINMACHINE] = rainmachine - except RequestError as err: - _LOGGER.error('An error occurred: %s', str(err)) - hass.components.persistent_notification.create( - 'Error: {0}
' - 'You will need to restart hass after fixing.' - ''.format(err), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False + except RainMachineError as err: + _LOGGER.error('An error occurred: %s', err) + raise ConfigEntryNotReady - for component, schema in [ - ('binary_sensor', conf[CONF_BINARY_SENSORS]), - ('sensor', conf[CONF_SENSORS]), - ('switch', conf[CONF_SWITCHES]), - ]: + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine + + for component in ('binary_sensor', 'sensor', 'switch'): hass.async_create_task( - discovery.async_load_platform(hass, component, DOMAIN, schema, - config)) + hass.config_entries.async_forward_entry_setup( + config_entry, component)) - async def refresh_sensors(event_time): + async def refresh(event_time): """Refresh RainMachine sensor data.""" _LOGGER.debug('Updating RainMachine sensor data') await rainmachine.async_update() async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC) - async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + hass.data[DOMAIN][DATA_LISTENER][ + config_entry.entry_id] = async_track_time_interval( + hass, + refresh, + timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) async def start_program(service): """Start a particular program.""" @@ -170,8 +193,8 @@ async def async_setup(hass, config): async def start_zone(service): """Start a particular zone for a certain amount of time.""" - await rainmachine.client.zones.start(service.data[CONF_ZONE_ID], - service.data[CONF_ZONE_RUN_TIME]) + await rainmachine.client.zones.start( + service.data[CONF_ZONE_ID], service.data[CONF_ZONE_RUN_TIME]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async def stop_all(service): @@ -201,14 +224,34 @@ async def async_setup(hass, config): return True +async def async_unload_entry(hass, config_entry): + """Unload an OpenUV config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop( + config_entry.entry_id) + remove_listener() + + for component in ('binary_sensor', 'sensor', 'switch'): + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + return True + + class RainMachine: """Define a generic RainMachine object.""" - def __init__(self, client): + def __init__( + self, client, binary_sensor_conditions, sensor_conditions, + default_zone_runtime): """Initialize.""" + self.binary_sensor_conditions = binary_sensor_conditions self.client = client + self.default_zone_runtime = default_zone_runtime self.device_mac = self.client.mac self.restrictions = {} + self.sensor_conditions = sensor_conditions async def async_update(self): """Update sensor/binary sensor data.""" @@ -224,9 +267,25 @@ class RainMachineEntity(Entity): def __init__(self, rainmachine): """Initialize.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._dispatcher_handlers = [] self._name = None self.rainmachine = rainmachine + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + 'identifiers': { + (DOMAIN, self.rainmachine.client.mac) + }, + 'name': self.rainmachine.client.name, + 'manufacturer': 'RainMachine', + 'model': 'Version {0} (API: {1})'.format( + self.rainmachine.client.hardware_version, + self.rainmachine.client.api_version), + 'sw_version': self.rainmachine.client.software_version, + } + @property def device_state_attributes(self) -> dict: """Return the state attributes.""" @@ -236,3 +295,8 @@ class RainMachineEntity(Entity): def name(self) -> str: """Return the name of the entity.""" return self._name + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + for handler in self._dispatcher_handlers: + handler() diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py new file mode 100644 index 00000000000..ecf497333cb --- /dev/null +++ b/homeassistant/components/rainmachine/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow to configure the RainMachine component.""" + +from collections import OrderedDict + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL) +from homeassistant.helpers import aiohttp_client + +from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured RainMachine instances.""" + return set( + entry.data[CONF_IP_ADDRESS] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class RainMachineFlowHandler(config_entries.ConfigFlow): + """Handle a RainMachine config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = OrderedDict() + self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str + self.data_schema[vol.Required(CONF_PASSWORD)] = str + self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id='user', + data_schema=vol.Schema(self.data_schema), + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from regenmaschine import login + from regenmaschine.errors import RainMachineError + + if not user_input: + return await self._show_form() + + if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass): + return await self._show_form({ + CONF_IP_ADDRESS: 'identifier_exists' + }) + + websession = aiohttp_client.async_get_clientsession(self.hass) + + try: + await login( + user_input[CONF_IP_ADDRESS], + user_input[CONF_PASSWORD], + websession, + port=user_input.get(CONF_PORT, DEFAULT_PORT), + ssl=True) + except RainMachineError: + return await self._show_form({ + CONF_PASSWORD: 'invalid_credentials' + }) + + scan_interval = user_input.get( + CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds + + # Unfortunately, RainMachine doesn't provide a way to refresh the + # access token without using the IP address and password, so we have to + # store it: + return self.async_create_entry( + title=user_input[CONF_IP_ADDRESS], data=user_input) diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py new file mode 100644 index 00000000000..ec1f0436ccb --- /dev/null +++ b/homeassistant/components/rainmachine/const.py @@ -0,0 +1,14 @@ +"""Define constants for the SimpliSafe component.""" +import logging +from datetime import timedelta + +LOGGER = logging.getLogger('homeassistant.components.rainmachine') + +DOMAIN = 'rainmachine' + +DATA_CLIENT = 'client' + +DEFAULT_PORT = 8080 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + +TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json new file mode 100644 index 00000000000..6e26192ec82 --- /dev/null +++ b/homeassistant/components/rainmachine/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "RainMachine", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "ip_address": "Hostname or IP Address", + "password": "Password", + "port": "Port" + } + } + }, + "error": { + "identifier_exists": "Account already registered", + "invalid_credentials": "Invalid credentials" + } + } +} diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index bc624ca5f89..ddb508d1282 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -34,7 +34,7 @@ from . import migration, purge from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.13'] +REQUIREMENTS = ['sqlalchemy==1.2.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 45c8f939faf..825f402aef2 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -29,7 +29,7 @@ def migrate_schema(instance): with open(progress_path, 'w'): pass - _LOGGER.warning("Database requires upgrade. Schema version: %s", + _LOGGER.warning("Database is about to upgrade. Schema version: %s", current_version) if current_version is None: @@ -218,6 +218,8 @@ def _apply_update(engine, new_version, old_version): ]) _create_index(engine, "states", "ix_states_context_id") _create_index(engine, "states", "ix_states_context_user_id") + elif new_version == 7: + _create_index(engine, "states", "ix_states_entity_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 700dd57eacf..7a655c29434 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -17,7 +17,7 @@ from homeassistant.helpers.json import JSONEncoder # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 6 +SCHEMA_VERSION = 7 _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ class States(Base): # type: ignore __tablename__ = 'states' state_id = Column(Integer, primary_key=True) domain = Column(String(64)) - entity_id = Column(String(255)) + entity_id = Column(String(255), index=True) state = Column(String(255)) attributes = Column(Text) event_id = Column(Integer, ForeignKey('events.event_id'), index=True) @@ -86,7 +86,8 @@ class States(Base): # type: ignore # Used for fetching the state of entities at a specific time # (get_states in history.py) Index( - 'ix_states_entity_id_last_updated', 'entity_id', 'last_updated'),) + 'ix_states_entity_id_last_updated', 'entity_id', 'last_updated'), + ) @staticmethod def from_event(event): diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 0f63357c0dc..915f38745a4 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -128,14 +128,14 @@ async def async_setup_platform(hass, config, async_add_entities, slot = service.data.get(CONF_SLOT, entity.slot) - await hass.async_add_job(device.learn, slot) + await hass.async_add_executor_job(device.learn, slot) timeout = service.data.get(CONF_TIMEOUT, entity.timeout) _LOGGER.info("Press the key you want Home Assistant to learn") start_time = utcnow() while (utcnow() - start_time) < timedelta(seconds=timeout): - message = await hass.async_add_job( + message = await hass.async_add_executor_job( device.read, slot) _LOGGER.debug("Message received from device: '%s'", message) @@ -148,7 +148,7 @@ async def async_setup_platform(hass, config, async_add_entities, if ('error' in message and message['error']['message'] == "learn timeout"): - await hass.async_add_job(device.learn, slot) + await hass.async_add_executor_job(device.learn, slot) await asyncio.sleep(1, loop=hass.loop) diff --git a/homeassistant/components/scene/deconz.py b/homeassistant/components/scene/deconz.py index 6319e52f6ef..05845a02288 100644 --- a/homeassistant/components/scene/deconz.py +++ b/homeassistant/components/scene/deconz.py @@ -4,7 +4,7 @@ Support for deCONZ scenes. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/scene.deconz/ """ -from homeassistant.components.deconz import DOMAIN as DATA_DECONZ +from homeassistant.components.deconz import DOMAIN as DECONZ_DOMAIN from homeassistant.components.scene import Scene from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,30 +20,32 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up scenes for deCONZ component.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_scene(scenes): """Add scene from deCONZ.""" entities = [] for scene in scenes: - entities.append(DeconzScene(scene)) + entities.append(DeconzScene(scene, gateway)) async_add_entities(entities) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_scene', async_add_scene)) - async_add_scene(hass.data[DATA_DECONZ].api.scenes.values()) + async_add_scene(gateway.api.scenes.values()) class DeconzScene(Scene): """Representation of a deCONZ scene.""" - def __init__(self, scene): + def __init__(self, scene, gateway): """Set up a scene.""" self._scene = scene + self.gateway = gateway async def async_added_to_hass(self): """Subscribe to sensors events.""" - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._scene.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id async def async_will_remove_from_hass(self) -> None: """Disconnect scene object when removed.""" diff --git a/homeassistant/components/sensor/.translations/moon.es.json b/homeassistant/components/sensor/.translations/moon.es.json index bbc03820b5b..3ce14cd4c77 100644 --- a/homeassistant/components/sensor/.translations/moon.es.json +++ b/homeassistant/components/sensor/.translations/moon.es.json @@ -2,6 +2,9 @@ "state": { "first_quarter": "Primer cuarto", "full_moon": "Luna llena", - "new_moon": "Luna nueva" + "last_quarter": "\u00daltimo cuarto", + "new_moon": "Luna nueva", + "waning_crescent": "Luna menguante", + "waxing_crescent": "Luna creciente" } } \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.fi.json b/homeassistant/components/sensor/.translations/moon.fi.json new file mode 100644 index 00000000000..10f8bb9b8a6 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.fi.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Ensimm\u00e4inen nelj\u00e4nnes", + "full_moon": "T\u00e4ysikuu", + "last_quarter": "Viimeinen nelj\u00e4nnes", + "new_moon": "Uusikuu", + "waning_crescent": "V\u00e4henev\u00e4 sirppi", + "waning_gibbous": "V\u00e4henev\u00e4 kuperakuu", + "waxing_crescent": "Kasvava sirppi", + "waxing_gibbous": "Kasvava kuperakuu" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.it.json b/homeassistant/components/sensor/.translations/moon.it.json index 014437e3fe4..f22a6d340ae 100644 --- a/homeassistant/components/sensor/.translations/moon.it.json +++ b/homeassistant/components/sensor/.translations/moon.it.json @@ -4,6 +4,7 @@ "full_moon": "Luna piena", "last_quarter": "Ultimo quarto", "new_moon": "Nuova luna", + "waning_crescent": "Luna calante", "waning_gibbous": "Gibbosa calante", "waxing_crescent": "Luna crescente", "waxing_gibbous": "Gibbosa crescente" diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 6837b9e1b2f..ff99dce5e06 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -41,33 +41,39 @@ SENSOR_TYPE_LEVEL = 'air_pollution_level' SENSOR_TYPE_AQI = 'air_quality_index' SENSOR_TYPE_POLLUTANT = 'main_pollutant' SENSORS = [ - (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:scale', None), - (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:format-list-numbers', 'AQI'), + (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:gauge', None), + (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:chart-line', 'AQI'), (SENSOR_TYPE_POLLUTANT, 'Main Pollutant', 'mdi:chemical-weapon', None), ] POLLUTANT_LEVEL_MAPPING = [{ 'label': 'Good', + 'icon': 'mdi:emoticon-excited', 'minimum': 0, 'maximum': 50 }, { 'label': 'Moderate', + 'icon': 'mdi:emoticon-happy', 'minimum': 51, 'maximum': 100 }, { - 'label': 'Unhealthy for sensitive group', + 'label': 'Unhealthy for sensitive groups', + 'icon': 'mdi:emoticon-neutral', 'minimum': 101, 'maximum': 150 }, { 'label': 'Unhealthy', + 'icon': 'mdi:emoticon-sad', 'minimum': 151, 'maximum': 200 }, { 'label': 'Very Unhealthy', + 'icon': 'mdi:emoticon-dead', 'minimum': 201, 'maximum': 300 }, { 'label': 'Hazardous', + 'icon': 'mdi:biohazard', 'minimum': 301, 'maximum': 10000 }] @@ -237,6 +243,7 @@ class AirVisualSensor(Entity): if i['minimum'] <= aqi <= i['maximum'] ] self._state = level['label'] + self._icon = level['icon'] elif self._type == SENSOR_TYPE_AQI: self._state = data['aqi{0}'.format(self._locale)] elif self._type == SENSOR_TYPE_POLLUTANT: diff --git a/homeassistant/components/sensor/asuswrt.py b/homeassistant/components/sensor/asuswrt.py new file mode 100644 index 00000000000..4ca088fb1e2 --- /dev/null +++ b/homeassistant/components/sensor/asuswrt.py @@ -0,0 +1,126 @@ +""" +Asuswrt status sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.asuswrt/ +""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.components.asuswrt import DATA_ASUSWRT + +DEPENDENCIES = ['asuswrt'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, add_entities, discovery_info=None): + """Set up the asuswrt sensors.""" + api = hass.data[DATA_ASUSWRT] + add_entities([ + AsuswrtRXSensor(api), + AsuswrtTXSensor(api), + AsuswrtTotalRXSensor(api), + AsuswrtTotalTXSensor(api) + ]) + + +class AsuswrtSensor(Entity): + """Representation of a asuswrt sensor.""" + + _name = 'generic' + + def __init__(self, api): + """Initialize the sensor.""" + self._api = api + self._state = None + self._rates = None + self._speed = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + async def async_update(self): + """Fetch status from asuswrt.""" + self._rates = await self._api.async_get_packets_total() + self._speed = await self._api.async_get_current_transfer_rates() + + +class AsuswrtRXSensor(AsuswrtSensor): + """Representation of a asuswrt download speed sensor.""" + + _name = 'Asuswrt Download Speed' + _unit = 'Mbit/s' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed is not None: + self._state = round(self._speed[0] / 125000, 2) + + +class AsuswrtTXSensor(AsuswrtSensor): + """Representation of a asuswrt upload speed sensor.""" + + _name = 'Asuswrt Upload Speed' + _unit = 'Mbit/s' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._speed is not None: + self._state = round(self._speed[1] / 125000, 2) + + +class AsuswrtTotalRXSensor(AsuswrtSensor): + """Representation of a asuswrt total download sensor.""" + + _name = 'Asuswrt Download' + _unit = 'Gigabyte' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates is not None: + self._state = round(self._rates[0] / 1000000000, 1) + + +class AsuswrtTotalTXSensor(AsuswrtSensor): + """Representation of a asuswrt total upload sensor.""" + + _name = 'Asuswrt Upload' + _unit = 'Gigabyte' + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit + + async def async_update(self): + """Fetch new state data for the sensor.""" + await super().async_update() + if self._rates is not None: + self._state = round(self._rates[1] / 1000000000, 1) diff --git a/homeassistant/components/sensor/coinbase.py b/homeassistant/components/sensor/coinbase.py index 40444dee93c..d25b7b786f8 100644 --- a/homeassistant/components/sensor/coinbase.py +++ b/homeassistant/components/sensor/coinbase.py @@ -9,17 +9,20 @@ from homeassistant.helpers.entity import Entity ATTR_NATIVE_BALANCE = "Balance in native currency" -BTC_ICON = 'mdi:currency-btc' - -COIN_ICON = 'mdi:coin' +CURRENCY_ICONS = { + 'BTC': 'mdi:currency-btc', + 'ETH': 'mdi:currency-eth', + 'EUR': 'mdi:currency-eur', + 'LTC': 'mdi:litecoin', + 'USD': 'mdi:currency-usd' +} +DEFAULT_COIN_ICON = 'mdi:coin' CONF_ATTRIBUTION = "Data provided by coinbase.com" DATA_COINBASE = 'coinbase_cache' DEPENDENCIES = ['coinbase'] -ETH_ICON = 'mdi:currency-eth' - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Coinbase sensors.""" @@ -68,11 +71,7 @@ class AccountSensor(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self._name == "Coinbase BTC Wallet": - return BTC_ICON - if self._name == "Coinbase ETH Wallet": - return ETH_ICON - return COIN_ICON + return CURRENCY_ICONS.get(self._unit_of_measurement, DEFAULT_COIN_ICON) @property def device_state_attributes(self): @@ -122,11 +121,7 @@ class ExchangeRateSensor(Entity): @property def icon(self): """Return the icon to use in the frontend, if any.""" - if self._name == "BTC Exchange Rate": - return BTC_ICON - if self._name == "ETH Exchange Rate": - return ETH_ICON - return COIN_ICON + return CURRENCY_ICONS.get(self.currency, DEFAULT_COIN_ICON) @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 9a3ba45dfa1..d4546d7b721 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -43,7 +43,8 @@ DEPRECATED_SENSOR_TYPES = { # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { - 'summary': ['Summary', None, None, None, None, None, None, ['daily']], + 'summary': ['Summary', None, None, None, None, None, None, + ['currently', 'hourly', 'daily']], 'minutely_summary': ['Minutely Summary', None, None, None, None, None, None, []], 'hourly_summary': ['Hourly Summary', None, None, None, None, None, None, @@ -72,7 +73,7 @@ SENSOR_TYPES = { ['hourly', 'daily']], 'temperature': ['Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly']], + ['currently', 'hourly', 'daily']], 'apparent_temperature': ['Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly']], @@ -98,15 +99,13 @@ SENSOR_TYPES = { ['currently', 'hourly', 'daily']], 'apparent_temperature_max': ['Daily High Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'apparent_temperature_high': ["Daytime High Apparent Temperature", '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['daily']], 'apparent_temperature_min': ['Daily Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'apparent_temperature_low': ['Overnight Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['daily']], @@ -124,8 +123,7 @@ SENSOR_TYPES = { ['daily']], 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h', - 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + 'mdi:thermometer', ['daily']], 'uv_index': ['UV Index', UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX, 'mdi:weather-sunny', @@ -135,16 +133,26 @@ SENSOR_TYPES = { } CONDITION_PICTURES = { - 'clear-day': '/static/images/darksky/weather-sunny.svg', - 'clear-night': '/static/images/darksky/weather-night.svg', - 'rain': '/static/images/darksky/weather-pouring.svg', - 'snow': '/static/images/darksky/weather-snowy.svg', - 'sleet': '/static/images/darksky/weather-hail.svg', - 'wind': '/static/images/darksky/weather-windy.svg', - 'fog': '/static/images/darksky/weather-fog.svg', - 'cloudy': '/static/images/darksky/weather-cloudy.svg', - 'partly-cloudy-day': '/static/images/darksky/weather-partlycloudy.svg', - 'partly-cloudy-night': '/static/images/darksky/weather-cloudy.svg', + 'clear-day': ['/static/images/darksky/weather-sunny.svg', + 'mdi:weather-sunny'], + 'clear-night': ['/static/images/darksky/weather-night.svg', + 'mdi:weather-sunny'], + 'rain': ['/static/images/darksky/weather-pouring.svg', + 'mdi:weather-pouring'], + 'snow': ['/static/images/darksky/weather-snowy.svg', + 'mdi:weather-snowy'], + 'sleet': ['/static/images/darksky/weather-hail.svg', + 'mdi:weather-snowy-rainy'], + 'wind': ['/static/images/darksky/weather-windy.svg', + 'mdi:weather-windy'], + 'fog': ['/static/images/darksky/weather-fog.svg', + 'mdi:weather-fog'], + 'cloudy': ['/static/images/darksky/weather-cloudy.svg', + 'mdi:weather-cloudy'], + 'partly-cloudy-day': ['/static/images/darksky/weather-partlycloudy.svg', + 'mdi:weather-partlycloudy'], + 'partly-cloudy-night': ['/static/images/darksky/weather-cloudy.svg', + 'mdi:weather-partlycloudy'], } # Language Supported Codes @@ -170,7 +178,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=300)): ( vol.All(cv.time_period, cv.positive_timedelta)), vol.Optional(CONF_FORECAST): - vol.All(cv.ensure_list, [vol.Range(min=1, max=7)]), + vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), }) @@ -205,7 +213,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for variable in config[CONF_MONITORED_CONDITIONS]: if variable in DEPRECATED_SENSOR_TYPES: _LOGGER.warning("Monitored condition %s is deprecated", variable) - sensors.append(DarkSkySensor(forecast_data, variable, name)) + if (not SENSOR_TYPES[variable][7] or + 'currently' in SENSOR_TYPES[variable][7]): + sensors.append(DarkSkySensor(forecast_data, variable, name)) if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: for forecast_day in forecast: sensors.append(DarkSkySensor( @@ -217,7 +227,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class DarkSkySensor(Entity): """Implementation of a Dark Sky sensor.""" - def __init__(self, forecast_data, sensor_type, name, forecast_day=0): + def __init__(self, forecast_data, sensor_type, name, forecast_day=None): """Initialize the sensor.""" self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] @@ -231,7 +241,7 @@ class DarkSkySensor(Entity): @property def name(self): """Return the name of the sensor.""" - if self.forecast_day == 0: + if self.forecast_day is None: return '{} {}'.format(self.client_name, self._name) return '{} {} {}'.format( @@ -259,7 +269,7 @@ class DarkSkySensor(Entity): return None if self._icon in CONDITION_PICTURES: - return CONDITION_PICTURES[self._icon] + return CONDITION_PICTURES[self._icon][0] return None @@ -277,6 +287,9 @@ class DarkSkySensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" + if 'summary' in self.type and self._icon in CONDITION_PICTURES: + return CONDITION_PICTURES[self._icon][1] + return SENSOR_TYPES[self.type][6] @property @@ -305,30 +318,18 @@ class DarkSkySensor(Entity): hourly = self.forecast_data.data_hourly self._state = getattr(hourly, 'summary', '') self._icon = getattr(hourly, 'icon', '') - elif self.forecast_day > 0 or ( - self.type in ['daily_summary', - 'temperature_min', - 'temperature_low', - 'temperature_max', - 'temperature_high', - 'apparent_temperature_min', - 'apparent_temperature_low', - 'apparent_temperature_max', - 'apparent_temperature_high', - 'precip_intensity_max', - 'precip_accumulation', - 'moon_phase']): + elif self.type == 'daily_summary': self.forecast_data.update_daily() daily = self.forecast_data.data_daily - if self.type == 'daily_summary': - self._state = getattr(daily, 'summary', '') - self._icon = getattr(daily, 'icon', '') + self._state = getattr(daily, 'summary', '') + self._icon = getattr(daily, 'icon', '') + elif self.forecast_day is not None: + self.forecast_data.update_daily() + daily = self.forecast_data.data_daily + if hasattr(daily, 'data'): + self._state = self.get_state(daily.data[self.forecast_day]) else: - if hasattr(daily, 'data'): - self._state = self.get_state( - daily.data[self.forecast_day]) - else: - self._state = 0 + self._state = 0 else: self.forecast_data.update_currently() currently = self.forecast_data.data_currently @@ -353,6 +354,7 @@ class DarkSkySensor(Entity): # percentages if self.type in ['precip_probability', 'cloud_cover', 'humidity']: return round(state * 100, 1) + if self.type in ['dew_point', 'temperature', 'apparent_temperature', 'temperature_low', 'apparent_temperature_low', 'temperature_min', 'apparent_temperature_min', diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 99f450d018e..e2c9b59c59c 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -5,8 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ from homeassistant.components.deconz.const import ( - ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, - DECONZ_DOMAIN) + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DECONZ_REACHABLE, + DOMAIN as DECONZ_DOMAIN) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback @@ -30,6 +30,8 @@ async def async_setup_platform(hass, config, async_add_entities, async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ sensors.""" + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_sensor(sensors): """Add sensors from deCONZ.""" @@ -41,32 +43,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): not (not allow_clip_sensor and sensor.type.startswith('CLIP')): if sensor.type in DECONZ_REMOTE: if sensor.battery: - entities.append(DeconzBattery(sensor)) + entities.append(DeconzBattery(sensor, gateway)) else: - entities.append(DeconzSensor(sensor)) + entities.append(DeconzSensor(sensor, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) - async_add_sensor(hass.data[DATA_DECONZ].api.sensors.values()) + async_add_sensor(gateway.api.sensors.values()) class DeconzSensor(Entity): """Representation of a sensor.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Set up sensor and add update callback to get data from websocket.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -116,7 +123,7 @@ class DeconzSensor(Entity): @property def available(self): """Return true if sensor is available.""" - return self._sensor.reachable + return self.gateway.available and self._sensor.reachable @property def should_poll(self): @@ -148,7 +155,7 @@ class DeconzSensor(Entity): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, @@ -163,20 +170,26 @@ class DeconzSensor(Entity): class DeconzBattery(Entity): """Battery class for when a device is only represented as an event.""" - def __init__(self, sensor): + def __init__(self, sensor, gateway): """Register dispatcher callback for update of battery state.""" self._sensor = sensor + self.gateway = gateway + self.unsub_dispatcher = None + self._name = '{} {}'.format(self._sensor.name, 'Battery Level') self._unit_of_measurement = "%" async def async_added_to_hass(self): """Subscribe to sensors events.""" self._sensor.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._sensor.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._sensor.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect sensor object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._sensor.remove_callback(self.async_update_callback) self._sensor = None @@ -211,6 +224,11 @@ class DeconzBattery(Entity): """Return the unit of measurement of this entity.""" return self._unit_of_measurement + @property + def available(self): + """Return true if sensor is available.""" + return self.gateway.available and self._sensor.reachable + @property def should_poll(self): """No polling needed.""" @@ -231,7 +249,7 @@ class DeconzBattery(Entity): self._sensor.uniqueid.count(':') != 7): return None serial = self._sensor.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/sensor/elkm1.py b/homeassistant/components/sensor/elkm1.py index 288f968b2f7..3fd57b190a6 100644 --- a/homeassistant/components/sensor/elkm1.py +++ b/homeassistant/components/sensor/elkm1.py @@ -91,6 +91,7 @@ class ElkKeypad(ElkSensor): attrs['last_user'] = self._element.last_user + 1 attrs['code'] = self._element.code attrs['last_user_name'] = username(self._elk, self._element.last_user) + attrs['last_keypress'] = self._element.last_keypress return attrs def _element_changed(self, element, changeset): diff --git a/homeassistant/components/sensor/fibaro.py b/homeassistant/components/sensor/fibaro.py new file mode 100644 index 00000000000..e5ed5638c5b --- /dev/null +++ b/homeassistant/components/sensor/fibaro.py @@ -0,0 +1,99 @@ +""" +Support for Fibaro sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fibaro/ +""" +import logging + +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +SENSOR_TYPES = { + 'com.fibaro.temperatureSensor': + ['Temperature', None, None, DEVICE_CLASS_TEMPERATURE], + 'com.fibaro.smokeSensor': + ['Smoke', 'ppm', 'mdi:fire', None], + 'CO2': + ['CO2', 'ppm', 'mdi:cloud', None], + 'com.fibaro.humiditySensor': + ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'com.fibaro.lightSensor': + ['Light', 'lx', None, DEVICE_CLASS_ILLUMINANCE] +} + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro controller devices.""" + if discovery_info is None: + return + + add_entities( + [FibaroSensor(device, hass.data[FIBARO_CONTROLLER]) + for device in hass.data[FIBARO_DEVICES]['sensor']], True) + + +class FibaroSensor(FibaroDevice, Entity): + """Representation of a Fibaro Sensor.""" + + def __init__(self, fibaro_device, controller): + """Initialize the sensor.""" + self.current_value = None + self.last_changed_time = None + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + if fibaro_device.type in SENSOR_TYPES: + self._unit = SENSOR_TYPES[fibaro_device.type][1] + self._icon = SENSOR_TYPES[fibaro_device.type][2] + self._device_class = SENSOR_TYPES[fibaro_device.type][3] + else: + self._unit = None + self._icon = None + self._device_class = None + try: + if not self._unit: + if self.fibaro_device.properties.unit == 'lux': + self._unit = 'lx' + elif self.fibaro_device.properties.unit == 'C': + self._unit = TEMP_CELSIUS + elif self.fibaro_device.properties.unit == 'F': + self._unit = TEMP_FAHRENHEIT + else: + self._unit = self.fibaro_device.properties.unit + except (KeyError, ValueError): + pass + + @property + def state(self): + """Return the state of the sensor.""" + return self.current_value + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class + + def update(self): + """Update the state.""" + try: + self.current_value = float(self.fibaro_device.properties.value) + except (KeyError, ValueError): + pass diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py new file mode 100644 index 00000000000..2c3598044bd --- /dev/null +++ b/homeassistant/components/sensor/flunearyou.py @@ -0,0 +1,213 @@ +""" +Support for user- and CDC-based flu info sensors from Flu Near You. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.flunearyou/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_STATE, CONF_LATITUDE, CONF_MONITORED_CONDITIONS, + CONF_LONGITUDE) +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyflunearyou==0.0.2'] +_LOGGER = logging.getLogger(__name__) + +ATTR_CITY = 'city' +ATTR_REPORTED_DATE = 'reported_date' +ATTR_REPORTED_LATITUDE = 'reported_latitude' +ATTR_REPORTED_LONGITUDE = 'reported_longitude' +ATTR_ZIP_CODE = 'zip_code' + +DEFAULT_ATTRIBUTION = 'Data provided by Flu Near You' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) +SCAN_INTERVAL = timedelta(minutes=30) + +CATEGORY_CDC_REPORT = 'cdc_report' +CATEGORY_USER_REPORT = 'user_report' + +TYPE_CDC_LEVEL = 'level' +TYPE_CDC_LEVEL2 = 'level2' +TYPE_USER_CHICK = 'chick' +TYPE_USER_DENGUE = 'dengue' +TYPE_USER_FLU = 'flu' +TYPE_USER_LEPTO = 'lepto' +TYPE_USER_NO_NONE = 'none' +TYPE_USER_SYMPTOMS = 'symptoms' +TYPE_USER_TOTAL = 'total' + +SENSORS = { + CATEGORY_CDC_REPORT: [ + (TYPE_CDC_LEVEL, 'CDC Level', 'mdi:biohazard', None), + (TYPE_CDC_LEVEL2, 'CDC Level 2', 'mdi:biohazard', None), + ], + CATEGORY_USER_REPORT: [ + (TYPE_USER_CHICK, 'Avian Flu Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_DENGUE, 'Dengue Fever Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_FLU, 'Flu Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_LEPTO, 'Leptospirosis Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_NO_NONE, 'No Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_SYMPTOMS, 'Flu-like Symptoms', 'mdi:alert', 'reports'), + (TYPE_USER_TOTAL, 'Total Symptoms', 'mdi:alert', 'reports'), + ] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + from pyflunearyou import create_client + from pyflunearyou.errors import FluNearYouError + + websession = aiohttp_client.async_get_clientsession(hass) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + identifier = '{0},{1}'.format(latitude, longitude) + + try: + client = await create_client(latitude, longitude, websession) + except FluNearYouError as err: + _LOGGER.error('There was an error while setting up: %s', err) + return + + fny = FluNearYouData(client, config[CONF_MONITORED_CONDITIONS]) + await fny.async_update() + + sensors = [ + FluNearYouSensor(fny, kind, name, identifier, category, icon, unit) + for category in config[CONF_MONITORED_CONDITIONS] + for kind, name, icon, unit in SENSORS[category] + ] + + async_add_entities(sensors, True) + + +class FluNearYouSensor(Entity): + """Define a base Flu Near You sensor.""" + + def __init__(self, fny, kind, name, identifier, category, icon, unit): + """Initialize the sensor.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._category = category + self._icon = icon + self._identifier = identifier + self._kind = kind + self._name = name + self._state = None + self._unit = unit + self.fny = fny + + @property + def available(self): + """Return True if entity is available.""" + return bool(self.fny.data[self._category]) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format(self._identifier, self._kind) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + async def async_update(self): + """Update the sensor.""" + await self.fny.async_update() + + cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT) + user_data = self.fny.data.get(CATEGORY_USER_REPORT) + + if self._category == CATEGORY_CDC_REPORT and cdc_data: + self._attrs.update({ + ATTR_REPORTED_DATE: cdc_data['week_date'], + ATTR_STATE: cdc_data['name'], + }) + self._state = cdc_data[self._kind] + elif self._category == CATEGORY_USER_REPORT and user_data: + self._attrs.update({ + ATTR_CITY: user_data['city'].split('(')[0], + ATTR_REPORTED_LATITUDE: user_data['latitude'], + ATTR_REPORTED_LONGITUDE: user_data['longitude'], + ATTR_ZIP_CODE: user_data['zip'], + }) + + if self._kind == TYPE_USER_TOTAL: + self._state = sum( + v for k, v in user_data.items() if k in ( + TYPE_USER_CHICK, TYPE_USER_DENGUE, TYPE_USER_FLU, + TYPE_USER_LEPTO, TYPE_USER_SYMPTOMS)) + else: + self._state = user_data[self._kind] + + +class FluNearYouData: + """Define a data object to retrieve info from Flu Near You.""" + + def __init__(self, client, sensor_types): + """Initialize.""" + self._client = client + self._sensor_types = sensor_types + self.data = {} + + async def _get_data(self, category, method): + """Get data for a specific category.""" + from pyflunearyou.errors import FluNearYouError + + try: + self.data[category] = await method() + except FluNearYouError as err: + _LOGGER.error( + 'There was an error with "%s" data: %s', category, err) + self.data[category] = {} + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Flu Near You data.""" + if CATEGORY_CDC_REPORT in self._sensor_types: + await self._get_data( + CATEGORY_CDC_REPORT, self._client.cdc_reports.status) + + if CATEGORY_USER_REPORT in self._sensor_types: + await self._get_data( + CATEGORY_USER_REPORT, self._client.user_reports.status) + + _LOGGER.debug('New data stored: %s', self.data) diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index 317416a15b8..397f08d8a7c 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -63,8 +63,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - phonebook_id = config.get('phonebook') - prefixes = config.get('prefixes') + phonebook_id = config.get(CONF_PHONEBOOK) + prefixes = config.get(CONF_PREFIXES) try: phonebook = FritzBoxPhonebook( @@ -156,8 +156,10 @@ class FritzBoxCallMonitor: def connect(self): """Connect to the Fritz!Box.""" + _LOGGER.debug('Setting up socket...') self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.settimeout(10) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) try: self.sock.connect((self.host, self.port)) threading.Thread(target=self._listen).start() @@ -168,6 +170,7 @@ class FritzBoxCallMonitor: def _listen(self): """Listen to incoming or outgoing calls.""" + _LOGGER.debug('Connection established, waiting for response...') while not self.stopped.isSet(): try: response = self.sock.recv(2048) @@ -175,10 +178,12 @@ class FritzBoxCallMonitor: # if no response after 10 seconds, just recv again continue response = str(response, "utf-8") + _LOGGER.debug('Received %s', response) if not response: # if the response is empty, the connection has been lost. # try to reconnect + _LOGGER.warning('Connection lost, reconnecting...') self.sock = None while self.sock is None: self.connect() diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index c2127827ebd..1dfb7a206c6 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -11,14 +11,15 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_RESOURCES, TEMP_CELSIUS) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, + CONF_VERIFY_SSL, CONF_RESOURCES, TEMP_CELSIUS) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['glances_api==0.1.0'] +REQUIREMENTS = ['glances_api==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -54,8 +55,12 @@ SENSOR_TYPES = { PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, vol.Optional(CONF_RESOURCES, default=['disk_use']): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), @@ -67,15 +72,20 @@ async def async_setup_platform( """Set up the Glances sensors.""" from glances_api import Glances - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - version = config.get(CONF_VERSION) - var_conf = config.get(CONF_RESOURCES) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + version = config[CONF_VERSION] + var_conf = config[CONF_RESOURCES] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + ssl = config[CONF_SSL] + verify_ssl = config[CONF_VERIFY_SSL] - session = async_get_clientsession(hass) + session = async_get_clientsession(hass, verify_ssl) glances = GlancesData( - Glances(hass.loop, session, host=host, port=port, version=version)) + Glances(hass.loop, session, host=host, port=port, version=version, + username=username, password=password, ssl=ssl)) await glances.async_update() diff --git a/homeassistant/components/sensor/jewish_calendar.py b/homeassistant/components/sensor/jewish_calendar.py index 2c917ba0f3b..8058a411266 100644 --- a/homeassistant/components/sensor/jewish_calendar.py +++ b/homeassistant/components/sensor/jewish_calendar.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -REQUIREMENTS = ['hdate==0.6.5'] +REQUIREMENTS = ['hdate==0.7.5'] _LOGGER = logging.getLogger(__name__) @@ -113,7 +113,6 @@ class JewishCalSensor(Entity): _LOGGER.debug("Now: %s Timezone = %s", now, now.tzinfo) today = now.date() - upcoming_saturday = today + timedelta((12 - today.weekday()) % 7) sunset = dt_util.as_local(get_astral_event_date( self.hass, SUN_EVENT_SUNSET, today)) @@ -124,30 +123,15 @@ class JewishCalSensor(Entity): date = hdate.HDate( today, diaspora=self.diaspora, hebrew=self._hebrew) - upcoming_shabbat = hdate.HDate( - upcoming_saturday, diaspora=self.diaspora, hebrew=self._hebrew) if self.type == 'date': - self._state = hdate.date.get_hebrew_date( - date.h_day, date.h_month, date.h_year, hebrew=self._hebrew) + self._state = date.hebrew_date elif self.type == 'weekly_portion': - self._state = hdate.date.get_parashe( - upcoming_shabbat.get_reading(self.diaspora), - hebrew=self._hebrew) + self._state = date.parasha elif self.type == 'holiday_name': - try: - description = next( - x.description[self._hebrew] - for x in hdate.htables.HOLIDAYS - if x.index == date.get_holyday()) - if not self._hebrew: - self._state = description - else: - self._state = description.long - except StopIteration: - self._state = None + self._state = date.holiday_description elif self.type == 'holyness': - self._state = hdate.date.get_holyday_type(date.get_holyday()) + self._state = date.holiday_type else: times = hdate.Zmanim( date=today, latitude=self.latitude, longitude=self.longitude, diff --git a/homeassistant/components/sensor/launch_library.py b/homeassistant/components/sensor/launch_library.py new file mode 100644 index 00000000000..0d109006818 --- /dev/null +++ b/homeassistant/components/sensor/launch_library.py @@ -0,0 +1,92 @@ +""" +A sensor platform that give you information about the next space launch. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.launch_library/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['pylaunches==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data provided by Launch Library." + +DEFAULT_NAME = 'Next launch' + +SCAN_INTERVAL = timedelta(hours=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + }) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the launch sensor.""" + from pylaunches.api import Launches + + name = config[CONF_NAME] + + session = async_get_clientsession(hass) + launches = Launches(hass.loop, session) + sensor = [LaunchLibrarySensor(launches, name)] + async_add_entities(sensor, True) + + +class LaunchLibrarySensor(Entity): + """Representation of a launch_library Sensor.""" + + def __init__(self, launches, name): + """Initialize the sensor.""" + self.launches = launches + self._attributes = {} + self._name = name + self._state = None + + async def async_update(self): + """Get the latest data.""" + await self.launches.get_launches() + if self.launches.launches is None: + _LOGGER.error("No data recieved") + return + try: + data = self.launches.launches[0] + self._state = data['name'] + self._attributes['launch_time'] = data['start'] + self._attributes['agency'] = data['agency'] + agency_country_code = data['agency_country_code'] + self._attributes['agency_country_code'] = agency_country_code + self._attributes['stream'] = data['stream'] + self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data, %s", error) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return 'mdi:rocket' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index 445ccb7214e..4752286b9b2 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -4,152 +4,120 @@ Support for Luftdaten sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ -from datetime import timedelta import logging -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.components.luftdaten import ( + DATA_LUFTDATEN, DATA_LUFTDATEN_CLIENT, DEFAULT_ATTRIBUTION, DOMAIN, + SENSORS, TOPIC_UPDATE) +from homeassistant.components.luftdaten.const import ATTR_SENSOR_ID from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_MONITORED_CONDITIONS, - CONF_NAME, CONF_SHOW_ON_MAP, TEMP_CELSIUS) -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv + ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -REQUIREMENTS = ['luftdaten==0.2.0'] _LOGGER = logging.getLogger(__name__) -ATTR_SENSOR_ID = 'sensor_id' - -CONF_ATTRIBUTION = "Data provided by luftdaten.info" - - -VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' - -SENSOR_TEMPERATURE = 'temperature' -SENSOR_HUMIDITY = 'humidity' -SENSOR_PM10 = 'P1' -SENSOR_PM2_5 = 'P2' -SENSOR_PRESSURE = 'pressure' - -SENSOR_TYPES = { - SENSOR_TEMPERATURE: ['Temperature', TEMP_CELSIUS], - SENSOR_HUMIDITY: ['Humidity', '%'], - SENSOR_PRESSURE: ['Pressure', 'Pa'], - SENSOR_PM10: ['PM10', VOLUME_MICROGRAMS_PER_CUBIC_METER], - SENSOR_PM2_5: ['PM2.5', VOLUME_MICROGRAMS_PER_CUBIC_METER] -} - -DEFAULT_NAME = 'Luftdaten' - -CONF_SENSORID = 'sensorid' - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_SENSORID): cv.positive_int, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, -}) +DEPENDENCIES = ['luftdaten'] async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the Luftdaten sensor.""" - from luftdaten import Luftdaten + """Set up an Luftdaten sensor based on existing config.""" + pass - name = config.get(CONF_NAME) - show_on_map = config.get(CONF_SHOW_ON_MAP) - sensor_id = config.get(CONF_SENSORID) - session = async_get_clientsession(hass) - luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a Luftdaten sensor based on a config entry.""" + luftdaten = hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][entry.entry_id] - await luftdaten.async_update() + sensors = [] + for sensor_type in luftdaten.sensor_conditions: + name, icon, unit = SENSORS[sensor_type] + sensors.append( + LuftdatenSensor( + luftdaten, sensor_type, name, icon, unit, + entry.data[CONF_SHOW_ON_MAP]) + ) - if luftdaten.data is None: - _LOGGER.error("Sensor is not available: %s", sensor_id) - return - - devices = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - if luftdaten.data.values[variable] is None: - _LOGGER.warning("It might be that sensor %s is not providing " - "measurements for %s", sensor_id, variable) - devices.append( - LuftdatenSensor(luftdaten, name, variable, sensor_id, show_on_map)) - - async_add_entities(devices) + async_add_entities(sensors, True) class LuftdatenSensor(Entity): """Implementation of a Luftdaten sensor.""" - def __init__(self, luftdaten, name, sensor_type, sensor_id, show): + def __init__( + self, luftdaten, sensor_type, name, icon, unit, show): """Initialize the Luftdaten sensor.""" + self._async_unsub_dispatcher_connect = None self.luftdaten = luftdaten + self._icon = icon self._name = name - self._state = None - self._sensor_id = sensor_id + self._data = None self.sensor_type = sensor_type - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._unit_of_measurement = unit self._show_on_map = show + self._attrs = {} @property - def name(self): - """Return the name of the sensor.""" - return '{} {}'.format(self._name, SENSOR_TYPES[self.sensor_type][0]) + def icon(self): + """Return the icon.""" + return self._icon @property def state(self): """Return the state of the device.""" - return self.luftdaten.data.values[self.sensor_type] + return self._data[self.sensor_type] @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, friendly identifier for this entity.""" + return '{0}_{1}'.format(self._data['sensor_id'], self.sensor_type) + @property def device_state_attributes(self): """Return the state attributes.""" - onmap = ATTR_LATITUDE, ATTR_LONGITUDE - nomap = 'lat', 'long' - lat_format, lon_format = onmap if self._show_on_map else nomap + self._attrs[ATTR_SENSOR_ID] = self._data['sensor_id'] + self._attrs[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION + + on_map = ATTR_LATITUDE, ATTR_LONGITUDE + no_map = 'lat', 'long' + lat_format, lon_format = on_map if self._show_on_map else no_map try: - attr = { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_SENSOR_ID: self._sensor_id, - lat_format: self.luftdaten.data.meta['latitude'], - lon_format: self.luftdaten.data.meta['longitude'], - } - return attr + self._attrs[lon_format] = self._data['longitude'] + self._attrs[lat_format] = self._data['latitude'] + return self._attrs except KeyError: return + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + async def async_update(self): - """Get the latest data from luftdaten.info and update the state.""" - await self.luftdaten.async_update() - - -class LuftdatenData: - """Class for handling the data retrieval.""" - - def __init__(self, data): - """Initialize the data object.""" - self.data = data - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Get the latest data from luftdaten.info.""" - from luftdaten.exceptions import LuftdatenError - + """Get the latest data and update the state.""" try: - await self.data.async_get_data() - except LuftdatenError: - _LOGGER.error("Unable to retrieve data from luftdaten.info") + self._data = self.luftdaten.data[DATA_LUFTDATEN] + except KeyError: + return diff --git a/homeassistant/components/sensor/magicseaweed.py b/homeassistant/components/sensor/magicseaweed.py index 59f38553d79..e14af6c3392 100644 --- a/homeassistant/components/sensor/magicseaweed.py +++ b/homeassistant/components/sensor/magicseaweed.py @@ -16,7 +16,7 @@ import homeassistant.util.dt as dt_util from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['magicseaweed==1.0.0'] +REQUIREMENTS = ['magicseaweed==1.0.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/melissa.py b/homeassistant/components/sensor/melissa.py deleted file mode 100644 index c11c5c76740..00000000000 --- a/homeassistant/components/sensor/melissa.py +++ /dev/null @@ -1,101 +0,0 @@ -""" -Support for Melissa climate Sensors. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.melissa/ -""" -import logging - -from homeassistant.components.melissa import DATA_MELISSA -from homeassistant.const import TEMP_CELSIUS -from homeassistant.helpers.entity import Entity - -DEPENDENCIES = ['melissa'] - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None): - """Set up the melissa sensor platform.""" - sensors = [] - api = hass.data[DATA_MELISSA] - - devices = (await api.async_fetch_devices()).values() - - for device in devices: - if device['type'] == 'melissa': - sensors.append(MelissaTemperatureSensor(device, api)) - sensors.append(MelissaHumiditySensor(device, api)) - async_add_entities(sensors) - - -class MelissaSensor(Entity): - """Representation of a Melissa Sensor.""" - - _type = 'generic' - - def __init__(self, device, api): - """Initialize the sensor.""" - self._api = api - self._state = None - self._name = '{0} {1}'.format( - device['name'], - self._type - ) - self._serial = device['serial_number'] - self._data = device['controller_log'] - - @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 - - async def async_update(self): - """Fetch status from melissa.""" - self._data = await self._api.async_status(cached=True) - - -class MelissaTemperatureSensor(MelissaSensor): - """Representation of a Melissa temperature Sensor.""" - - _type = 'temperature' - _unit = TEMP_CELSIUS - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - async def async_update(self): - """Fetch new state data for the sensor.""" - await super().async_update() - try: - self._state = self._data[self._serial]['temp'] - except KeyError: - _LOGGER.warning("Unable to get temperature for %s", self.entity_id) - - -class MelissaHumiditySensor(MelissaSensor): - """Representation of a Melissa humidity Sensor.""" - - _type = 'humidity' - _unit = '%' - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - async def async_update(self): - """Fetch new state data for the sensor.""" - await super().async_update() - try: - self._state = self._data[self._serial]['humidity'] - except KeyError: - _LOGGER.warning("Unable to get humidity for %s", self.entity_id) diff --git a/homeassistant/components/sensor/point.py b/homeassistant/components/sensor/point.py new file mode 100644 index 00000000000..0c099c8873e --- /dev/null +++ b/homeassistant/components/sensor/point.py @@ -0,0 +1,68 @@ +""" +Support for Minut Point. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.point/ +""" +import logging + +from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point.const import ( + DOMAIN as POINT_DOMAIN, NEW_DEVICE) +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.util.dt import parse_datetime + +_LOGGER = logging.getLogger(__name__) + +DEVICE_CLASS_SOUND = 'sound_level' + +SENSOR_TYPES = { + DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), + DEVICE_CLASS_PRESSURE: (None, 0, 'hPa'), + DEVICE_CLASS_HUMIDITY: (None, 1, '%'), + DEVICE_CLASS_SOUND: ('mdi:ear-hearing', 1, 'dBa'), +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up a Point's sensors based on a config entry.""" + device_id = config_entry.data[NEW_DEVICE] + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities((MinutPointSensor(client, device_id, sensor_type) + for sensor_type in SENSOR_TYPES), True) + + +class MinutPointSensor(MinutPointEntity): + """The platform class required by Home Assistant.""" + + def __init__(self, point_client, device_id, device_class): + """Initialize the entity.""" + super().__init__(point_client, device_id, device_class) + self._device_prop = SENSOR_TYPES[device_class] + + @callback + def _update_callback(self): + """Update the value of the sensor.""" + if self.is_updated: + _LOGGER.debug('Update sensor value for %s', self) + self._value = self.device.sensor(self.device_class) + self._updated = parse_datetime(self.device.last_update) + self.async_schedule_update_ha_state() + + @property + def icon(self): + """Return the icon representation.""" + return self._device_prop[0] + + @property + def state(self): + """Return the state of the sensor.""" + return round(self.value, self._device_prop[1]) + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._device_prop[2] diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 818404aa3fe..8f187b82fd2 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -4,21 +4,22 @@ Support for Pollen.com allergen and cold/flu sensors. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.pollen/ """ -import logging from datetime import timedelta +import logging from statistics import mean import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS) from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.15.3', 'pypollencom==2.2.2'] +REQUIREMENTS = ['numpy==1.15.4', 'pypollencom==2.2.2'] + _LOGGER = logging.getLogger(__name__) ATTR_ALLERGEN_AMOUNT = 'allergen_amount' @@ -62,11 +63,11 @@ SENSORS = { 'IndexSensor', 'Allergy Index: Tomorrow', 'mdi:flower'), TYPE_ALLERGY_YESTERDAY: ( 'IndexSensor', 'Allergy Index: Yesterday', 'mdi:flower'), - TYPE_ASTHMA_TODAY: ('IndexSensor', 'Ashma Index: Today', 'mdi:flower'), + TYPE_ASTHMA_TODAY: ('IndexSensor', 'Asthma Index: Today', 'mdi:flower'), TYPE_ASTHMA_TOMORROW: ( - 'IndexSensor', 'Ashma Index: Tomorrow', 'mdi:flower'), + 'IndexSensor', 'Asthma Index: Tomorrow', 'mdi:flower'), TYPE_ASTHMA_YESTERDAY: ( - 'IndexSensor', 'Ashma Index: Yesterday', 'mdi:flower'), + 'IndexSensor', 'Asthma Index: Yesterday', 'mdi:flower'), TYPE_ASTHMA_FORECAST: ( 'ForecastSensor', 'Asthma Index: Forecasted Average', 'mdi:flower'), TYPE_ASTHMA_HISTORIC: ( @@ -244,6 +245,7 @@ class ForecastSensor(BaseSensor): if self._kind == TYPE_ALLERGY_FORECAST: outlook = self.pollen.data[TYPE_ALLERGY_OUTLOOK] self._attrs[ATTR_OUTLOOK] = outlook['Outlook'] + self._attrs[ATTR_SEASON] = outlook['Season'] self._state = average @@ -401,8 +403,8 @@ class PollenComData: await self._get_data( self._client.disease.extended, TYPE_DISEASE_FORECAST) - _LOGGER.debug('New data retrieved: %s', self.data) + _LOGGER.debug("New data retrieved: %s", self.data) except InvalidZipError: _LOGGER.error( - 'Cannot retrieve data for ZIP code: %s', self._client.zip_code) + "Cannot retrieve data for ZIP code: %s", self._client.zip_code) self.data = {} diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py index 20e95f0e98f..5131b25510a 100644 --- a/homeassistant/components/sensor/rainmachine.py +++ b/homeassistant/components/sensor/rainmachine.py @@ -7,26 +7,27 @@ https://home-assistant.io/components/sensor.rainmachine/ import logging from homeassistant.components.rainmachine import ( - DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, SENSORS, RainMachineEntity) -from homeassistant.const import CONF_MONITORED_CONDITIONS + DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, SENSOR_UPDATE_TOPIC, SENSORS, + RainMachineEntity) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['rainmachine'] - _LOGGER = logging.getLogger(__name__) async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine sensors based on the old way.""" + pass - rainmachine = hass.data[DATA_RAINMACHINE] + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine sensors based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] sensors = [] - for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + for sensor_type in rainmachine.sensor_conditions: name, icon, unit = SENSORS[sensor_type] sensors.append( RainMachineSensor(rainmachine, sensor_type, name, icon, unit)) @@ -73,15 +74,15 @@ class RainMachineSensor(RainMachineEntity): """Return the unit the value is expressed in.""" return self._unit - @callback - def _update_data(self): - """Update the state.""" - self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + @callback + def update(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, update)) async def async_update(self): """Update the sensor's state.""" diff --git a/homeassistant/components/sensor/ruter.py b/homeassistant/components/sensor/ruter.py new file mode 100644 index 00000000000..7b02b51d0c0 --- /dev/null +++ b/homeassistant/components/sensor/ruter.py @@ -0,0 +1,94 @@ +""" +A sensor platform that give you information about next departures from Ruter. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.ruter/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +REQUIREMENTS = ['pyruter==1.1.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_STOP_ID = 'stop_id' +CONF_DESTINATION = 'destination' +CONF_OFFSET = 'offset' + +DEFAULT_NAME = 'Ruter' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_ID): cv.positive_int, + vol.Optional(CONF_DESTINATION): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_OFFSET, default=0): cv.positive_int, + }) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the sensor.""" + from pyruter.api import Departures + + stop_id = config[CONF_STOP_ID] + destination = config.get(CONF_DESTINATION) + name = config[CONF_NAME] + offset = config[CONF_OFFSET] + + session = async_get_clientsession(hass) + ruter = Departures(hass.loop, stop_id, destination, session) + sensor = [RuterSensor(ruter, name, offset)] + async_add_entities(sensor, True) + + +class RuterSensor(Entity): + """Representation of a Ruter sensor.""" + + def __init__(self, ruter, name, offset): + """Initialize the sensor.""" + self.ruter = ruter + self._attributes = {} + self._name = name + self._offset = offset + self._state = None + + async def async_update(self): + """Get the latest data from the Ruter API.""" + await self.ruter.get_departures() + if self.ruter.departures is None: + _LOGGER.error("No data recieved from Ruter.") + return + try: + data = self.ruter.departures[self._offset] + self._state = data['time'] + self._attributes['line'] = data['line'] + self._attributes['destination'] = data['destination'] + except (KeyError, IndexError) as error: + _LOGGER.debug("Error getting data from Ruter, %s", error) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Return the icon of the sensor.""" + return 'mdi:bus' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py index 3b14c3854cd..c9b222f2b26 100644 --- a/homeassistant/components/sensor/season.py +++ b/homeassistant/components/sensor/season.py @@ -34,6 +34,13 @@ HEMISPHERE_SEASON_SWAP = {STATE_WINTER: STATE_SUMMER, STATE_AUTUMN: STATE_SPRING, STATE_SUMMER: STATE_WINTER} +SEASON_ICONS = { + STATE_SPRING: 'mdi:flower', + STATE_SUMMER: 'mdi:sunglasses', + STATE_AUTUMN: 'mdi:leaf', + STATE_WINTER: 'mdi:snowflake' +} + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In(VALID_TYPES) @@ -116,6 +123,11 @@ class Season(Entity): """Return the current season.""" return self.season + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return SEASON_ICONS.get(self.season, 'mdi:cloud') + def update(self): """Update season.""" self.datetime = datetime.now() diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py new file mode 100644 index 00000000000..7ad0e453760 --- /dev/null +++ b/homeassistant/components/sensor/seventeentrack.py @@ -0,0 +1,288 @@ +""" +Support for package tracking sensors from 17track.net. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.seventeentrack/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_PASSWORD, CONF_SCAN_INTERVAL, + CONF_USERNAME) +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.0.2'] +_LOGGER = logging.getLogger(__name__) + +ATTR_DESTINATION_COUNTRY = 'destination_country' +ATTR_INFO_TEXT = 'info_text' +ATTR_ORIGIN_COUNTRY = 'origin_country' +ATTR_PACKAGE_TYPE = 'package_type' +ATTR_TRACKING_INFO_LANGUAGE = 'tracking_info_language' + +CONF_SHOW_ARCHIVED = 'show_archived' +CONF_SHOW_DELIVERED = 'show_delivered' + +DATA_PACKAGES = 'package_data' +DATA_SUMMARY = 'summary_data' + +DEFAULT_ATTRIBUTION = 'Data provided by 17track.net' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +NOTIFICATION_DELIVERED_ID_SCAFFOLD = 'package_delivered_{0}' +NOTIFICATION_DELIVERED_TITLE = 'Package Delivered' +NOTIFICATION_DELIVERED_URL_SCAFFOLD = 'https://t.17track.net/track#nums={0}' + +VALUE_DELIVERED = 'Delivered' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SHOW_ARCHIVED, default=False): cv.boolean, + vol.Optional(CONF_SHOW_DELIVERED, default=False): cv.boolean, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Configure the platform and add the sensors.""" + from py17track import Client + from py17track.errors import SeventeenTrackError + + websession = aiohttp_client.async_get_clientsession(hass) + + client = Client(websession) + + try: + login_result = await client.profile.login( + config[CONF_USERNAME], config[CONF_PASSWORD]) + + if not login_result: + _LOGGER.error('Invalid username and password provided') + return + except SeventeenTrackError as err: + _LOGGER.error('There was an error while logging in: %s', err) + return + + scan_interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + data = SeventeenTrackData( + client, async_add_entities, scan_interval, config[CONF_SHOW_ARCHIVED], + config[CONF_SHOW_DELIVERED]) + await data.async_update() + + sensors = [] + + for status, quantity in data.summary.items(): + sensors.append(SeventeenTrackSummarySensor(data, status, quantity)) + + for package in data.packages: + sensors.append(SeventeenTrackPackageSensor(data, package)) + + async_add_entities(sensors, True) + + +class SeventeenTrackSummarySensor(Entity): + """Define a summary sensor.""" + + def __init__(self, data, status, initial_state): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._data = data + self._state = initial_state + self._status = status + + @property + def available(self): + """Return whether the entity is available.""" + return self._state is not None + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return 'mdi:package' + + @property + def name(self): + """Return the name.""" + return 'Packages {0}'.format(self._status) + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return 'summary_{0}_{1}'.format( + self._data.account_id, slugify(self._status)) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return 'packages' + + async def async_update(self): + """Update the sensor.""" + await self._data.async_update() + + self._state = self._data.summary.get(self._status) + + +class SeventeenTrackPackageSensor(Entity): + """Define an individual package sensor.""" + + def __init__(self, data, package): + """Initialize.""" + self._attrs = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_INFO_TEXT: package.info_text, + ATTR_LOCATION: package.location, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + } + self._data = data + self._state = package.status + self._tracking_number = package.tracking_number + + @property + def available(self): + """Return whether the entity is available.""" + return bool([ + p for p in self._data.packages + if p.tracking_number == self._tracking_number + ]) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return 'mdi:package' + + @property + def name(self): + """Return the name.""" + return self._tracking_number + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return 'package_{0}_{1}'.format( + self._data.account_id, self._tracking_number) + + async def async_update(self): + """Update the sensor.""" + await self._data.async_update() + + if not self._data.packages: + return + + try: + package = next(( + p for p in self._data.packages + if p.tracking_number == self._tracking_number)) + except StopIteration: + # If the package no longer exists in the data, log a message and + # delete this entity: + _LOGGER.info( + 'Deleting entity for stale package: %s', self._tracking_number) + self.hass.async_create_task(self.async_remove()) + return + + # If the user has elected to not see delivered packages and one gets + # delivered, post a notification and delete the entity: + if package.status == VALUE_DELIVERED and not self._data.show_delivered: + _LOGGER.info('Package delivered: %s', self._tracking_number) + self.hass.components.persistent_notification.create( + 'Package Delivered: {0}
' + 'Visit 17.track for more infomation: {1}' + ''.format( + self._tracking_number, + NOTIFICATION_DELIVERED_URL_SCAFFOLD.format( + self._tracking_number)), + title=NOTIFICATION_DELIVERED_TITLE, + notification_id=NOTIFICATION_DELIVERED_ID_SCAFFOLD.format( + self._tracking_number)) + self.hass.async_create_task(self.async_remove()) + return + + self._attrs.update({ + ATTR_INFO_TEXT: package.info_text, + ATTR_LOCATION: package.location, + }) + self._state = package.status + + +class SeventeenTrackData: + """Define a data handler for 17track.net.""" + + def __init__( + self, client, async_add_entities, scan_interval, show_archived, + show_delivered): + """Initialize.""" + self._async_add_entities = async_add_entities + self._client = client + self._scan_interval = scan_interval + self._show_archived = show_archived + self.account_id = client.profile.account_id + self.packages = [] + self.show_delivered = show_delivered + self.summary = {} + + self.async_update = Throttle(self._scan_interval)(self._async_update) + + async def _async_update(self): + """Get updated data from 17track.net.""" + from py17track.errors import SeventeenTrackError + + try: + packages = await self._client.profile.packages( + show_archived=self._show_archived) + _LOGGER.debug('New package data received: %s', packages) + + if not self.show_delivered: + packages = [p for p in packages if p.status != VALUE_DELIVERED] + + # Add new packages: + to_add = set(packages) - set(self.packages) + if self.packages and to_add: + self._async_add_entities([ + SeventeenTrackPackageSensor(self, package) + for package in to_add + ], True) + + self.packages = packages + except SeventeenTrackError as err: + _LOGGER.error('There was an error retrieving packages: %s', err) + self.packages = [] + + try: + self.summary = await self._client.profile.summary( + show_archived=self._show_archived) + _LOGGER.debug('New summary data received: %s', self.summary) + except SeventeenTrackError as err: + _LOGGER.error('There was an error retrieving the summary: %s', err) + self.summary = {} diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index fd12ea18088..b3ce8fc28a0 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.13'] +REQUIREMENTS = ['sqlalchemy==1.2.14'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/homeassistant/components/sensor/srp_energy.py b/homeassistant/components/sensor/srp_energy.py new file mode 100644 index 00000000000..8e1de24a2c5 --- /dev/null +++ b/homeassistant/components/sensor/srp_energy.py @@ -0,0 +1,149 @@ +""" +Platform for retrieving energy data from SRP. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/sensor.srp_energy/ +""" +from datetime import datetime, timedelta +import logging + +from requests.exceptions import ( + ConnectionError as ConnectError, HTTPError, Timeout) +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, + CONF_USERNAME, CONF_ID) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['srpenergy==1.0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by SRP Energy" + +DEFAULT_NAME = 'SRP Energy' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) +ENERGY_KWH = 'kWh' + +ATTR_READING_COST = "reading_cost" +ATTR_READING_TIME = 'datetime' +ATTR_READING_USAGE = 'reading_usage' +ATTR_DAILY_USAGE = 'daily_usage' +ATTR_USAGE_HISTORY = 'usage_history' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the SRP energy.""" + name = config[CONF_NAME] + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + account_id = config[CONF_ID] + + from srpenergy.client import SrpEnergyClient + + srp_client = SrpEnergyClient(account_id, username, password) + + if not srp_client.validate(): + _LOGGER.error("Couldn't connect to %s. Check credentials", name) + return + + add_entities([SrpEnergy(name, srp_client)], True) + + +class SrpEnergy(Entity): + """Representation of an srp usage.""" + + def __init__(self, name, client): + """Initialize SRP Usage.""" + self._state = None + self._name = name + self._client = client + self._history = None + self._usage = None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def state(self): + """Return the current state.""" + if self._state is None: + return None + + return "{0:.2f}".format(self._state) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return ENERGY_KWH + + @property + def history(self): + """Return the energy usage history of this entity, if any.""" + if self._usage is None: + return None + + history = [{ + ATTR_READING_TIME: isodate, + ATTR_READING_USAGE: kwh, + ATTR_READING_COST: cost + } for _, _, isodate, kwh, cost in self._usage] + + return history + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + ATTR_USAGE_HISTORY: self.history + } + + return attributes + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest usage from SRP Energy.""" + start_date = datetime.now() + timedelta(days=-1) + end_date = datetime.now() + + try: + + usage = self._client.usage(start_date, end_date) + + daily_usage = 0.0 + for _, _, _, kwh, _ in usage: + daily_usage += float(kwh) + + if usage: + + self._state = daily_usage + self._usage = usage + + else: + _LOGGER.error("Unable to fetch data from SRP. No data") + + except (ConnectError, HTTPError, Timeout) as error: + _LOGGER.error("Unable to connect to SRP. %s", error) + except ValueError as error: + _LOGGER.error("Value error connecting to SRP. %s", error) + except TypeError as error: + _LOGGER.error("Type error connecting to SRP. " + "Check username and password. %s", error) diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 453acb94b11..e7a35b5fdf0 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -13,7 +13,8 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT) + CONF_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, + ATTR_UNIT_OF_MEASUREMENT) from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change @@ -66,7 +67,7 @@ async def async_setup_platform(hass, config, async_add_entities, max_age = config.get(CONF_MAX_AGE, None) precision = config.get(CONF_PRECISION) - async_add_entities([StatisticsSensor(hass, entity_id, name, sampling_size, + async_add_entities([StatisticsSensor(entity_id, name, sampling_size, max_age, precision)], True) return True @@ -75,10 +76,9 @@ async def async_setup_platform(hass, config, async_add_entities, class StatisticsSensor(Entity): """Representation of a Statistics sensor.""" - def __init__(self, hass, entity_id, name, sampling_size, max_age, + def __init__(self, entity_id, name, sampling_size, max_age, precision): """Initialize the Statistics sensor.""" - self._hass = hass self._entity_id = entity_id self.is_binary = True if self._entity_id.split('.')[0] == \ 'binary_sensor' else False @@ -99,10 +99,8 @@ class StatisticsSensor(Entity): self.min_age = self.max_age = None self.change = self.average_change = self.change_rate = None - if 'recorder' in self._hass.config.components: - # only use the database if it's configured - hass.async_add_job(self._initialize_from_database) - + async def async_added_to_hass(self): + """Register callbacks.""" @callback def async_stats_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" @@ -111,10 +109,24 @@ class StatisticsSensor(Entity): self._add_state_to_queue(new_state) - hass.async_add_job(self.async_update_ha_state, True) + self.async_schedule_update_ha_state(True) - async_track_state_change( - hass, entity_id, async_stats_sensor_state_listener) + @callback + def async_stats_sensor_startup(event): + """Add listener and get recorded state.""" + _LOGGER.debug("Startup for %s", self.entity_id) + + async_track_state_change( + self.hass, self._entity_id, async_stats_sensor_state_listener) + + if 'recorder' in self.hass.config.components: + # only use the database if it's configured + self.hass.async_create_task( + self._async_initialize_from_database() + ) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_stats_sensor_startup) def _add_state_to_queue(self, new_state): try: @@ -173,12 +185,20 @@ class StatisticsSensor(Entity): """Remove states which are older than self._max_age.""" now = dt_util.utcnow() + _LOGGER.debug("%s: purging records older then %s(%s)", + self.entity_id, dt_util.as_local(now - self._max_age), + self._max_age) + while self.ages and (now - self.ages[0]) > self._max_age: + _LOGGER.debug("%s: purging record with datetime %s(%s)", + self.entity_id, dt_util.as_local(self.ages[0]), + (now - self.ages[0])) self.ages.popleft() self.states.popleft() async def async_update(self): """Get the latest data and updates the states.""" + _LOGGER.debug("%s: updating statistics.", self.entity_id) if self._max_age is not None: self._purge_old() @@ -191,7 +211,7 @@ class StatisticsSensor(Entity): self.median = round(statistics.median(self.states), self._precision) except statistics.StatisticsError as err: - _LOGGER.debug(err) + _LOGGER.debug("%s: %s", self.entity_id, err) self.mean = self.median = STATE_UNKNOWN try: # require at least two data points @@ -200,7 +220,7 @@ class StatisticsSensor(Entity): self.variance = round(statistics.variance(self.states), self._precision) except statistics.StatisticsError as err: - _LOGGER.debug(err) + _LOGGER.debug("%s: %s", self.entity_id, err) self.stdev = self.variance = STATE_UNKNOWN if self.states: @@ -233,20 +253,33 @@ class StatisticsSensor(Entity): self.change = self.average_change = STATE_UNKNOWN self.change_rate = STATE_UNKNOWN - async def _initialize_from_database(self): + async def _async_initialize_from_database(self): """Initialize the list of states from the database. The query will get the list of states in DESCENDING order so that we can limit the result to self._sample_size. Afterwards reverse the list so that we get it in the right order again. + + If MaxAge is provided then query will restrict to entries younger then + current datetime - MaxAge. """ from homeassistant.components.recorder.models import States - _LOGGER.debug("initializing values for %s from the database", + _LOGGER.debug("%s: initializing values from the database", self.entity_id) - with session_scope(hass=self._hass) as session: + with session_scope(hass=self.hass) as session: query = session.query(States)\ - .filter(States.entity_id == self._entity_id.lower())\ + .filter(States.entity_id == self._entity_id.lower()) + + if self._max_age is not None: + records_older_then = dt_util.utcnow() - self._max_age + _LOGGER.debug("%s: retrieve records not older then %s", + self.entity_id, records_older_then) + query = query.filter(States.last_updated >= records_older_then) + else: + _LOGGER.debug("%s: retrieving all records.", self.entity_id) + + query = query\ .order_by(States.last_updated.desc())\ .limit(self._sampling_size) states = execute(query) @@ -254,4 +287,7 @@ class StatisticsSensor(Entity): for state in reversed(states): self._add_state_to_queue(state) - _LOGGER.debug("initializing from database completed") + self.async_schedule_update_ha_state(True) + + _LOGGER.debug("%s: initializing from database completed", + self.entity_id) diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py index fb55c22b2e8..c354ebedb2b 100644 --- a/homeassistant/components/sensor/swiss_hydrological_data.py +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -4,145 +4,160 @@ Support for hydrological data from the Federal Office for the Environment FOEN. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_hydrological_data/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import requests from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - TEMP_CELSIUS, CONF_NAME, STATE_UNKNOWN, ATTR_ATTRIBUTION) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['xmltodict==0.11.0'] +REQUIREMENTS = ['swisshydrodata==0.0.3'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'http://www.hydrodata.ch/xml/SMS.xml' + +ATTRIBUTION = "Data provided by the Swiss Federal Office for the " \ + "Environment FOEN" + +ATTR_DELTA_24H = 'delta-24h' +ATTR_MAX_1H = 'max-1h' +ATTR_MAX_24H = 'max-24h' +ATTR_MEAN_1H = 'mean-1h' +ATTR_MEAN_24H = 'mean-24h' +ATTR_MIN_1H = 'min-1h' +ATTR_MIN_24H = 'min-24h' +ATTR_PREVIOUS_24H = 'previous-24h' +ATTR_STATION = 'station' +ATTR_STATION_UPDATE = 'station_update' +ATTR_WATER_BODY = 'water_body' +ATTR_WATER_BODY_TYPE = 'water_body_type' CONF_STATION = 'station' -CONF_ATTRIBUTION = "Data provided by the Swiss Federal Office for the " \ - "Environment FOEN" -DEFAULT_NAME = 'Water temperature' +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -ICON = 'mdi:cup-water' +SENSOR_DISCHARGE = 'discharge' +SENSOR_LEVEL = 'level' +SENSOR_TEMPERATURE = 'temperature' -ATTR_LOCATION = 'location' -ATTR_UPDATE = 'update' -ATTR_DISCHARGE = 'discharge' -ATTR_WATERLEVEL = 'level' -ATTR_DISCHARGE_MEAN = 'discharge_mean' -ATTR_WATERLEVEL_MEAN = 'level_mean' -ATTR_TEMPERATURE_MEAN = 'temperature_mean' -ATTR_DISCHARGE_MAX = 'discharge_max' -ATTR_WATERLEVEL_MAX = 'level_max' -ATTR_TEMPERATURE_MAX = 'temperature_max' +CONDITIONS = { + SENSOR_DISCHARGE: 'mdi:waves', + SENSOR_LEVEL: 'mdi:zodiac-aquarius', + SENSOR_TEMPERATURE: 'mdi:oil-temperature', +} -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +CONDITION_DETAILS = [ + ATTR_DELTA_24H, + ATTR_MAX_1H, + ATTR_MAX_24H, + ATTR_MEAN_1H, + ATTR_MEAN_24H, + ATTR_MIN_1H, + ATTR_MIN_24H, + ATTR_PREVIOUS_24H, +] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STATION): vol.Coerce(int), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_TEMPERATURE]): + vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), }) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Swiss hydrological sensor.""" - import xmltodict - - name = config.get(CONF_NAME) station = config.get(CONF_STATION) + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - try: - response = requests.get(_RESOURCE, timeout=5) - if any(str(station) == location.get('@StrNr') for location in - xmltodict.parse(response.text)['AKT_Data']['MesPar']) is False: - _LOGGER.error("The given station does not exist: %s", station) - return False - except requests.exceptions.ConnectionError: - _LOGGER.error("The URL is not accessible") - return False + hydro_data = HydrologicalData(station) + hydro_data.update() - data = HydrologicalData(station) - add_entities([SwissHydrologicalDataSensor(name, data)], True) + if hydro_data.data is None: + _LOGGER.error("The station doesn't exists: %s", station) + return + + entities = [] + + for condition in monitored_conditions: + entities.append( + SwissHydrologicalDataSensor(hydro_data, station, condition)) + + add_entities(entities, True) class SwissHydrologicalDataSensor(Entity): - """Implementation of an Swiss hydrological sensor.""" + """Implementation of a Swiss hydrological sensor.""" - def __init__(self, name, data): - """Initialize the sensor.""" - self.data = data - self._name = name - self._unit_of_measurement = TEMP_CELSIUS - self._state = None + def __init__(self, hydro_data, station, condition): + """Initialize the Swiss hydrological sensor.""" + self.hydro_data = hydro_data + self._condition = condition + self._data = self._state = self._unit_of_measurement = None + self._icon = CONDITIONS[condition] + self._station = station @property def name(self): """Return the name of the sensor.""" - return self._name + return "{0} {1}".format(self._data['water-body-name'], self._condition) + + @property + def unique_id(self) -> str: + """Return a unique, friendly identifier for this entity.""" + return '{0}_{1}'.format(self._station, self._condition) @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - if self._state is not STATE_UNKNOWN: - return self._unit_of_measurement + if self._state is not None: + return self.hydro_data.data['parameters'][self._condition]['unit'] return None @property def state(self): """Return the state of the sensor.""" - try: - return round(float(self._state), 1) - except ValueError: - return STATE_UNKNOWN + if isinstance(self._state, (int, float)): + return round(self._state, 2) + return None @property def device_state_attributes(self): - """Return the state attributes.""" - attributes = {} - if self.data.measurings is not None: - if '02' in self.data.measurings: - attributes[ATTR_WATERLEVEL] = self.data.measurings['02'][ - 'current'] - attributes[ATTR_WATERLEVEL_MEAN] = self.data.measurings['02'][ - 'mean'] - attributes[ATTR_WATERLEVEL_MAX] = self.data.measurings['02'][ - 'max'] - if '03' in self.data.measurings: - attributes[ATTR_TEMPERATURE_MEAN] = self.data.measurings['03'][ - 'mean'] - attributes[ATTR_TEMPERATURE_MAX] = self.data.measurings['03'][ - 'max'] - if '10' in self.data.measurings: - attributes[ATTR_DISCHARGE] = self.data.measurings['10'][ - 'current'] - attributes[ATTR_DISCHARGE_MEAN] = self.data.measurings['10'][ - 'current'] - attributes[ATTR_DISCHARGE_MAX] = self.data.measurings['10'][ - 'max'] + """Return the device state attributes.""" + attrs = {} - attributes[ATTR_LOCATION] = self.data.measurings['location'] - attributes[ATTR_UPDATE] = self.data.measurings['update_time'] - attributes[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION - return attributes + if not self._data: + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + return attrs + + attrs[ATTR_WATER_BODY_TYPE] = self._data['water-body-type'] + attrs[ATTR_STATION] = self._data['name'] + attrs[ATTR_STATION_UPDATE] = \ + self._data['parameters'][self._condition]['datetime'] + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + + for entry in CONDITION_DETAILS: + attrs[entry.replace('-', '_')] = \ + self._data['parameters'][self._condition][entry] + + return attrs @property def icon(self): - """Icon to use in the frontend, if any.""" - return ICON + """Icon to use in the frontend.""" + return self._icon def update(self): - """Get the latest data and update the states.""" - self.data.update() - if self.data.measurings is not None: - if '03' not in self.data.measurings: - self._state = STATE_UNKNOWN - else: - self._state = self.data.measurings['03']['current'] + """Get the latest data and update the state.""" + self.hydro_data.update() + self._data = self.hydro_data.data + + if self._data is None: + self._state = None + else: + self._state = self._data['parameters'][self._condition]['value'] class HydrologicalData: @@ -151,38 +166,12 @@ class HydrologicalData: def __init__(self, station): """Initialize the data object.""" self.station = station - self.measurings = None + self.data = None @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Get the latest data from hydrodata.ch.""" - import xmltodict + """Get the latest data.""" + from swisshydrodata import SwissHydroData - details = {} - try: - response = requests.get(_RESOURCE, timeout=5) - except requests.exceptions.ConnectionError: - _LOGGER.error("Unable to retrieve data from %s", _RESOURCE) - - try: - stations = xmltodict.parse(response.text)['AKT_Data']['MesPar'] - # Water level: Typ="02", temperature: Typ="03", discharge: Typ="10" - for station in stations: - if str(self.station) != station.get('@StrNr'): - continue - for data in ['02', '03', '10']: - if data != station.get('@Typ'): - continue - values = station.get('Wert') - if values is not None: - details[data] = { - 'current': values[0], - 'max': list(values[4].items())[1][1], - 'mean': list(values[3].items())[1][1]} - - details['location'] = station.get('Name') - details['update_time'] = station.get('Zeit') - - self.measurings = details - except AttributeError: - self.measurings = None + shd = SwissHydroData() + self.data = shd.get_station(self.station) diff --git a/homeassistant/components/sensor/tautulli.py b/homeassistant/components/sensor/tautulli.py new file mode 100644 index 00000000000..7b0d8e491d2 --- /dev/null +++ b/homeassistant/components/sensor/tautulli.py @@ -0,0 +1,148 @@ +""" +A platform which allows you to get information from Tautulli. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/sensor.tautulli/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PORT, + CONF_SSL, CONF_VERIFY_SSL) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pytautulli==0.4.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_MONITORED_USERS = 'monitored_users' + +DEFAULT_NAME = 'Tautulli' +DEFAULT_PORT = '8181' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_MONITORED_USERS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Create the Tautulli sensor.""" + from pytautulli import Tautulli + + name = config.get(CONF_NAME) + host = config[CONF_HOST] + port = config.get(CONF_PORT) + api_key = config[CONF_API_KEY] + monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) + user = config.get(CONF_MONITORED_USERS) + use_ssl = config.get(CONF_SSL) + verify_ssl = config.get(CONF_VERIFY_SSL) + + session = async_get_clientsession(hass, verify_ssl) + tautulli = TautulliData(Tautulli( + host, port, api_key, hass.loop, session, use_ssl)) + + if not await tautulli.test_connection(): + raise PlatformNotReady + + sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)] + + async_add_entities(sensor, True) + + +class TautulliSensor(Entity): + """Representation of a Tautulli sensor.""" + + def __init__(self, tautulli, name, monitored_conditions, users): + """Initialize the Tautulli sensor.""" + self.tautulli = tautulli + self.monitored_conditions = monitored_conditions + self.usernames = users + self.sessions = {} + self.home = {} + self._attributes = {} + self._name = name + self._state = None + + async def async_update(self): + """Get the latest data from the Tautulli API.""" + await self.tautulli.async_update() + self.home = self.tautulli.api.home_data + self.sessions = self.tautulli.api.session_data + self._attributes['Top Movie'] = self.home[0]['rows'][0]['title'] + self._attributes['Top TV Show'] = self.home[3]['rows'][0]['title'] + self._attributes['Top User'] = self.home[7]['rows'][0]['user'] + for key in self.sessions: + if 'sessions' not in key: + self._attributes[key] = self.sessions[key] + for user in self.tautulli.api.users: + if self.usernames is None or user in self.usernames: + userdata = self.tautulli.api.user_data + self._attributes[user] = {} + self._attributes[user]['Activity'] = userdata[user]['Activity'] + if self.monitored_conditions: + for key in self.monitored_conditions: + try: + self._attributes[user][key] = userdata[user][key] + except (KeyError, TypeError): + self._attributes[user][key] = '' + + @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.sessions['stream_count'] + + @property + def icon(self): + """Return the icon of the sensor.""" + return 'mdi:plex' + + @property + def device_state_attributes(self): + """Return attributes for the sensor.""" + return self._attributes + + +class TautulliData: + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the data object.""" + self.api = api + + @Throttle(TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from Tautulli.""" + await self.api.get_data() + + async def test_connection(self): + """Test connection to Tautulli.""" + await self.api.test_connection() + connection_status = self.api.connection + return connection_status diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 1207c8dfe20..703f2bbbd17 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -132,6 +132,8 @@ class TibberSensorElPrice(Entity): state = None max_price = 0 min_price = 10000 + sum_price = 0 + num = 0 now = dt_util.now() for key, price_total in self._tibber_home.price_total.items(): price_time = dt_util.as_local(dt_util.parse_datetime(key)) @@ -146,8 +148,11 @@ class TibberSensorElPrice(Entity): if now.date() == price_time.date(): max_price = max(max_price, price_total) min_price = min(min_price, price_total) + num += 1 + sum_price += price_total self._state = state self._device_state_attributes['max_price'] = max_price + self._device_state_attributes['avg_price'] = sum_price / num self._device_state_attributes['min_price'] = min_price return state is not None @@ -171,8 +176,16 @@ class TibberSensorRT(Entity): async def _async_callback(self, payload): """Handle received data.""" - data = payload.get('data', {}) - live_measurement = data.get('liveMeasurement', {}) + errors = payload.get('errors') + if errors: + _LOGGER.error(errors[0]) + return + data = payload.get('data') + if data is None: + return + live_measurement = data.get('liveMeasurement') + if live_measurement is None: + return self._state = live_measurement.pop('power', None) self._device_state_attributes = live_measurement self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/transport_nsw.py b/homeassistant/components/sensor/transport_nsw.py index 08a2907748c..2e28d81a2c3 100644 --- a/homeassistant/components/sensor/transport_nsw.py +++ b/homeassistant/components/sensor/transport_nsw.py @@ -4,6 +4,7 @@ Transport NSW (AU) sensor to query next leave event for a specified stop. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.transport_nsw/ """ +from datetime import timedelta import logging import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_API_KEY, ATTR_ATTRIBUTION) -REQUIREMENTS = ['PyTransportNSW==0.0.8'] +REQUIREMENTS = ['PyTransportNSW==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -22,19 +23,34 @@ ATTR_ROUTE = 'route' ATTR_DUE_IN = 'due' ATTR_DELAY = 'delay' ATTR_REAL_TIME = 'real_time' +ATTR_DESTINATION = 'destination' +ATTR_MODE = 'mode' CONF_ATTRIBUTION = "Data provided by Transport NSW" CONF_STOP_ID = 'stop_id' CONF_ROUTE = 'route' +CONF_DESTINATION = 'destination' DEFAULT_NAME = "Next Bus" -ICON = "mdi:bus" +ICONS = { + 'Train': 'mdi:train', + 'Lightrail': 'mdi:tram', + 'Bus': 'mdi:bus', + 'Coach': 'mdi:bus', + 'Ferry': 'mdi:ferry', + 'Schoolbus': 'mdi:bus', + 'n/a': 'mdi:clock', + None: 'mdi:clock' +} + +SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STOP_ID): cv.string, vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_ROUTE, default=""): cv.string, + vol.Optional(CONF_DESTINATION, default=""): cv.string }) @@ -43,9 +59,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): stop_id = config[CONF_STOP_ID] api_key = config[CONF_API_KEY] route = config.get(CONF_ROUTE) + destination = config.get(CONF_DESTINATION) name = config.get(CONF_NAME) - data = PublicTransportData(stop_id, route, api_key) + data = PublicTransportData(stop_id, route, destination, api_key) add_entities([TransportNSWSensor(data, stop_id, name)], True) @@ -58,6 +75,7 @@ class TransportNSWSensor(Entity): self._name = name self._stop_id = stop_id self._times = self._state = None + self._icon = ICONS[None] @property def name(self): @@ -79,6 +97,8 @@ class TransportNSWSensor(Entity): ATTR_ROUTE: self._times[ATTR_ROUTE], ATTR_DELAY: self._times[ATTR_DELAY], ATTR_REAL_TIME: self._times[ATTR_REAL_TIME], + ATTR_DESTINATION: self._times[ATTR_DESTINATION], + ATTR_MODE: self._times[ATTR_MODE], ATTR_ATTRIBUTION: CONF_ATTRIBUTION } @@ -90,36 +110,43 @@ class TransportNSWSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - return ICON + return self._icon def update(self): """Get the latest data from Transport NSW and update the states.""" self.data.update() self._times = self.data.info self._state = self._times[ATTR_DUE_IN] + self._icon = ICONS[self._times[ATTR_MODE]] class PublicTransportData: """The Class for handling the data retrieval.""" - def __init__(self, stop_id, route, api_key): + def __init__(self, stop_id, route, destination, api_key): """Initialize the data object.""" import TransportNSW self._stop_id = stop_id self._route = route + self._destination = destination self._api_key = api_key self.info = {ATTR_ROUTE: self._route, ATTR_DUE_IN: 'n/a', ATTR_DELAY: 'n/a', - ATTR_REAL_TIME: 'n/a'} + ATTR_REAL_TIME: 'n/a', + ATTR_DESTINATION: 'n/a', + ATTR_MODE: None} self.tnsw = TransportNSW.TransportNSW() def update(self): """Get the next leave time.""" _data = self.tnsw.get_departures(self._stop_id, self._route, + self._destination, self._api_key) self.info = {ATTR_ROUTE: _data['route'], ATTR_DUE_IN: _data['due'], ATTR_DELAY: _data['delay'], - ATTR_REAL_TIME: _data['real_time']} + ATTR_REAL_TIME: _data['real_time'], + ATTR_DESTINATION: _data['destination'], + ATTR_MODE: _data['mode']} diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index 11cb6832e40..8a2a7593b2c 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -16,15 +16,25 @@ from homeassistant.const import CONF_NAME, CONF_SOURCE from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyhaversion==2.0.2'] +REQUIREMENTS = ['pyhaversion==2.0.3'] _LOGGER = logging.getLogger(__name__) +ALL_IMAGES = [ + 'default', 'intel-nuc', 'qemux86', 'qemux86-64', 'qemuarm', + 'qemuarm-64', 'raspberrypi', 'raspberrypi2', 'raspberrypi3', + 'raspberrypi3-64', 'tinker', 'odroid-c2', 'odroid-xu' +] +ALL_SOURCES = [ + 'local', 'pypi', 'hassio', 'docker' +] + CONF_BETA = 'beta' CONF_IMAGE = 'image' DEFAULT_IMAGE = 'default' -DEFAULT_NAME = "Current Version" +DEFAULT_NAME_LATEST = "Latest Version" +DEFAULT_NAME_LOCAL = "Current Version" DEFAULT_SOURCE = 'local' ICON = 'mdi:package-up' @@ -33,11 +43,9 @@ TIME_BETWEEN_UPDATES = timedelta(minutes=5) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BETA, default=False): cv.boolean, - vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.All(cv.string, - vol.Lower), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.All(cv.string, - vol.Lower), + vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In(ALL_IMAGES), + vol.Optional(CONF_NAME, default=''): cv.string, + vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.In(ALL_SOURCES), }) @@ -63,7 +71,7 @@ async def async_setup_platform( class VersionSensor(Entity): """Representation of a Home Assistant version sensor.""" - def __init__(self, haversion, name): + def __init__(self, haversion, name=''): """Initialize the Version sensor.""" self.haversion = haversion self._name = name @@ -76,7 +84,11 @@ class VersionSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._name + if self._name: + return self._name + if self.haversion.source == DEFAULT_SOURCE: + return DEFAULT_NAME_LOCAL + return DEFAULT_NAME_LATEST @property def state(self): @@ -88,6 +100,11 @@ class VersionSensor(Entity): """Return attributes for the sensor.""" return self.haversion.api.version_data + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return ICON + class VersionData: """Get the latest data and update the states.""" diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index e4cc8381ede..c55c229f549 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -25,7 +25,7 @@ ATTR_DURATION = 'duration' ATTR_DISTANCE = 'distance' ATTR_ROUTE = 'route' -CONF_ATTRIBUTION = "Data provided by the Waze.com" +CONF_ATTRIBUTION = "Powered by Waze" CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' CONF_INCL_FILTER = 'incl_filter' @@ -37,7 +37,7 @@ DEFAULT_REALTIME = True ICON = 'mdi:car' -REGIONS = ['US', 'NA', 'EU', 'IL'] +REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index 86ee2f8767c..dddf7b23922 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -142,7 +142,7 @@ class XiaomiAirQualityMonitor(Entity): from miio import DeviceException try: - state = await self.hass.async_add_job(self._device.status) + state = await self.hass.async_add_executor_job(self._device.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index f113561429a..45650ece621 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -13,6 +13,7 @@ from homeassistant.components.http.data_validator import ( from homeassistant.helpers import intent import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json +from homeassistant.components import websocket_api ATTR_NAME = 'name' @@ -36,6 +37,13 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): vol.Any(None, cv.string) }) +WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' + +SCHEMA_WEBSOCKET_ITEMS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_ITEMS + }) + @asyncio.coroutine def async_setup(hass, config): @@ -91,6 +99,11 @@ def async_setup(hass, config): yield from hass.components.frontend.async_register_built_in_panel( 'shopping-list', 'shopping_list', 'mdi:cart') + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_ITEMS, + websocket_handle_items, + SCHEMA_WEBSOCKET_ITEMS) + return True @@ -256,3 +269,10 @@ class ClearCompletedItemsView(http.HomeAssistantView): hass.data[DOMAIN].async_clear_completed() hass.bus.async_fire(EVENT) return self.json_message('Cleared completed items.') + + +@callback +def websocket_handle_items(hass, connection, msg): + """Handle get shopping_list items.""" + connection.send_message(websocket_api.result_message( + msg['id'], hass.data[DOMAIN].items)) diff --git a/homeassistant/components/simplisafe/.translations/cs.json b/homeassistant/components/simplisafe/.translations/cs.json index 0dd9912de0d..f4a47c5c344 100644 --- a/homeassistant/components/simplisafe/.translations/cs.json +++ b/homeassistant/components/simplisafe/.translations/cs.json @@ -13,6 +13,7 @@ }, "title": "Vypl\u0148te va\u0161e \u00fadaje" } - } + }, + "title": "SimpliSafe" } } \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ko.json b/homeassistant/components/simplisafe/.translations/ko.json index eca099ed79d..5426c564e03 100644 --- a/homeassistant/components/simplisafe/.translations/ko.json +++ b/homeassistant/components/simplisafe/.translations/ko.json @@ -2,7 +2,7 @@ "config": { "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "invalid_credentials": "\uc774\uba54\uc77c \uc8fc\uc18c \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 2421addfd0c..0ca3bac3e35 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,5 +1,5 @@ """ -Component for the swedish weather institute weather service. +Component for the Swedish weather institute weather service. For more details about this component, please refer to the documentation at https://home-assistant.io/components/smhi/ @@ -7,8 +7,7 @@ https://home-assistant.io/components/smhi/ from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant -# Have to import for config_flow to work -# even if they are not used here +# Have to import for config_flow to work even if they are not used here from .config_flow import smhi_locations # noqa: F401 from .const import DOMAIN # noqa: F401 @@ -18,21 +17,21 @@ DEFAULT_NAME = 'smhi' async def async_setup(hass: HomeAssistant, config: Config) -> bool: - """Set up configured smhi.""" + """Set up configured SMHI.""" # We allow setup only through config flow type of config return True -async def async_setup_entry(hass: HomeAssistant, - config_entry: ConfigEntry) -> bool: - """Set up smhi forecast as config entry.""" +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up SMHI forecast as config entry.""" hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, 'weather')) return True -async def async_unload_entry(hass: HomeAssistant, - config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" await hass.config_entries.async_forward_entry_unload( config_entry, 'weather') diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index e461c6d195d..e1ebf81bac7 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -1,21 +1,11 @@ -"""Config flow to configure smhi component. - -First time the user creates the configuration and -a valid location is set in the hass configuration yaml -it will use that location and use it as default values. - -Additional locations can be added in config form. -The input location will be checked by invoking -the API. Exception will be thrown if the location -is not supported by the API (Swedish locations only) -""" +"""Config flow to configure SMHI component.""" import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant import config_entries, data_entry_flow -from homeassistant.const import (CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +import homeassistant.helpers.config_validation as cv from homeassistant.util import slugify from .const import DOMAIN, HOME_LOCATION_NAME @@ -45,9 +35,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): if user_input is not None: is_ok = await self._check_location( - user_input[CONF_LONGITUDE], - user_input[CONF_LATITUDE] - ) + user_input[CONF_LONGITUDE], user_input[CONF_LATITUDE]) if is_ok: name = slugify(user_input[CONF_NAME]) if not self._name_in_configuration_exists(name): @@ -60,9 +48,8 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): else: self._errors['base'] = 'wrong_location' - # If hass config has the location set and - # is a valid coordinate the default location - # is set as default values in the form + # If hass config has the location set and is a valid coordinate the + # default location is set as default values in the form if not smhi_locations(self.hass): if await self._homeassistant_location_exists(): return await self._show_config_form( @@ -79,8 +66,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): self.hass.config.longitude != 0.0: # Return true if valid location if await self._check_location( - self.hass.config.longitude, - self.hass.config.latitude): + self.hass.config.longitude, self.hass.config.latitude): return True return False @@ -90,9 +76,7 @@ class SmhiFlowHandler(data_entry_flow.FlowHandler): return True return False - async def _show_config_form(self, - name: str = None, - latitude: str = None, + async def _show_config_form(self, name: str = None, latitude: str = None, longitude: str = None): """Show the configuration form to edit location data.""" return self.async_show_form( diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py index 49e0f295873..9689857e546 100644 --- a/homeassistant/components/smhi/const.py +++ b/homeassistant/components/smhi/const.py @@ -1,12 +1,16 @@ """Constants in smhi component.""" import logging + from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN +ATTR_SMHI_CLOUDINESS = 'cloudiness' + +DOMAIN = 'smhi' + HOME_LOCATION_NAME = 'Home' -ATTR_SMHI_CLOUDINESS = 'cloudiness' -DOMAIN = 'smhi' -LOGGER = logging.getLogger('homeassistant.components.smhi') ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}" ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format( HOME_LOCATION_NAME) + +LOGGER = logging.getLogger('homeassistant.components.smhi') diff --git a/homeassistant/components/sonos/.translations/es.json b/homeassistant/components/sonos/.translations/es.json index c91f9a78c29..d2372a7d9b7 100644 --- a/homeassistant/components/sonos/.translations/es.json +++ b/homeassistant/components/sonos/.translations/es.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "no_devices_found": "No se han encontrado dispositivos Sonos en la red.", + "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos." + }, "step": { "confirm": { + "description": "\u00bfQuieres configurar Sonos?", "title": "Sonos" } }, diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index b794fe607e6..529df41de58 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow DOMAIN = 'sonos' -REQUIREMENTS = ['pysonos==0.0.3'] +REQUIREMENTS = ['pysonos==0.0.5'] async def async_setup(hass, config): diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py index 4c2fcca052c..b491bc4b567 100644 --- a/homeassistant/components/switch/deconz.py +++ b/homeassistant/components/switch/deconz.py @@ -5,7 +5,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.deconz/ """ from homeassistant.components.deconz.const import ( - DOMAIN as DATA_DECONZ, DECONZ_DOMAIN, POWER_PLUGS, SIRENS) + DECONZ_REACHABLE, DOMAIN as DECONZ_DOMAIN, POWER_PLUGS, SIRENS) from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE @@ -25,38 +25,45 @@ async def async_setup_entry(hass, config_entry, async_add_entities): Switches are based same device class as lights in deCONZ. """ + gateway = hass.data[DECONZ_DOMAIN] + @callback def async_add_switch(lights): """Add switch from deCONZ.""" entities = [] for light in lights: if light.type in POWER_PLUGS: - entities.append(DeconzPowerPlug(light)) + entities.append(DeconzPowerPlug(light, gateway)) elif light.type in SIRENS: - entities.append(DeconzSiren(light)) + entities.append(DeconzSiren(light, gateway)) async_add_entities(entities, True) - hass.data[DATA_DECONZ].listeners.append( + gateway.listeners.append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) - async_add_switch(hass.data[DATA_DECONZ].api.lights.values()) + async_add_switch(gateway.api.lights.values()) class DeconzSwitch(SwitchDevice): """Representation of a deCONZ switch.""" - def __init__(self, switch): + def __init__(self, switch, gateway): """Set up switch and add update callback to get data from websocket.""" self._switch = switch + self.gateway = gateway + self.unsub_dispatcher = None async def async_added_to_hass(self): """Subscribe to switches events.""" self._switch.register_async_callback(self.async_update_callback) - self.hass.data[DATA_DECONZ].deconz_ids[self.entity_id] = \ - self._switch.deconz_id + self.gateway.deconz_ids[self.entity_id] = self._switch.deconz_id + self.unsub_dispatcher = async_dispatcher_connect( + self.hass, DECONZ_REACHABLE, self.async_update_callback) async def async_will_remove_from_hass(self) -> None: """Disconnect switch object when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() self._switch.remove_callback(self.async_update_callback) self._switch = None @@ -78,7 +85,7 @@ class DeconzSwitch(SwitchDevice): @property def available(self): """Return True if light is available.""" - return self._switch.reachable + return self.gateway.available and self._switch.reachable @property def should_poll(self): @@ -92,7 +99,7 @@ class DeconzSwitch(SwitchDevice): self._switch.uniqueid.count(':') != 7): return None serial = self._switch.uniqueid.split('-', 1)[0] - bridgeid = self.hass.data[DATA_DECONZ].api.config.bridgeid + bridgeid = self.gateway.api.config.bridgeid return { 'connections': {(CONNECTION_ZIGBEE, serial)}, 'identifiers': {(DECONZ_DOMAIN, serial)}, diff --git a/homeassistant/components/switch/fibaro.py b/homeassistant/components/switch/fibaro.py new file mode 100644 index 00000000000..d3e96646a45 --- /dev/null +++ b/homeassistant/components/switch/fibaro.py @@ -0,0 +1,68 @@ +""" +Support for Fibaro switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.fibaro/ +""" +import logging + +from homeassistant.util import convert +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Fibaro switches.""" + if discovery_info is None: + return + + add_entities( + [FibaroSwitch(device, hass.data[FIBARO_CONTROLLER]) for + device in hass.data[FIBARO_DEVICES]['switch']], True) + + +class FibaroSwitch(FibaroDevice, SwitchDevice): + """Representation of a Fibaro Switch.""" + + def __init__(self, fibaro_device, controller): + """Initialize the Fibaro device.""" + self._state = False + super().__init__(fibaro_device, controller) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + def turn_on(self, **kwargs): + """Turn device on.""" + self.call_turn_on() + self._state = True + + def turn_off(self, **kwargs): + """Turn device off.""" + self.call_turn_off() + self._state = False + + @property + def current_power_w(self): + """Return the current power usage in W.""" + if 'power' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.power, float, 0.0) + return None + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if 'energy' in self.fibaro_device.interfaces: + return convert(self.fibaro_device.properties.energy, float, 0.0) + return None + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def update(self): + """Update device state.""" + self._state = self.current_binary_state diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index ea7aded3e16..fdd0c09b9d7 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -19,7 +19,7 @@ from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.const import ( ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE, SERVICE_TURN_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import slugify from homeassistant.util.color import ( @@ -67,7 +67,8 @@ PLATFORM_SCHEMA = vol.Schema({ }) -def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): +async def async_set_lights_xy(hass, lights, x_val, y_val, brightness, + transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -79,11 +80,11 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): service_data[ATTR_WHITE_VALUE] = brightness if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -def set_lights_temp(hass, lights, mired, brightness, transition): +async def async_set_lights_temp(hass, lights, mired, brightness, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -94,11 +95,11 @@ def set_lights_temp(hass, lights, mired, brightness, transition): service_data[ATTR_BRIGHTNESS] = brightness if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -def set_lights_rgb(hass, lights, rgb, transition): +async def async_set_lights_rgb(hass, lights, rgb, transition): """Set color of array of lights.""" for light in lights: if is_on(hass, light): @@ -107,11 +108,12 @@ def set_lights_rgb(hass, lights, rgb, transition): service_data[ATTR_RGB_COLOR] = rgb if transition is not None: service_data[ATTR_TRANSITION] = transition - hass.services.call( + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, service_data) -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 Flux switches.""" name = config.get(CONF_NAME) lights = config.get(CONF_LIGHTS) @@ -129,14 +131,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): start_colortemp, sunset_colortemp, stop_colortemp, brightness, disable_brightness_adjust, mode, interval, transition) - add_entities([flux]) + async_add_entities([flux]) - def update(call=None): + async def async_update(call=None): """Update lights.""" - flux.flux_update() + await flux.async_flux_update() service_name = slugify("{} {}".format(name, 'update')) - hass.services.register(DOMAIN, service_name, update) + hass.services.async_register(DOMAIN, service_name, async_update) class FluxSwitch(SwitchDevice): @@ -172,30 +174,30 @@ class FluxSwitch(SwitchDevice): """Return true if switch is on.""" return self.unsub_tracker is not None - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn on flux.""" if self.is_on: return - # Make initial update - self.flux_update() - - self.unsub_tracker = track_time_interval( + self.unsub_tracker = async_track_time_interval( self.hass, - self.flux_update, + self.async_flux_update, datetime.timedelta(seconds=self._interval)) - self.schedule_update_ha_state() + # Make initial update + await self.async_flux_update() - def turn_off(self, **kwargs): + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): """Turn off flux.""" - if self.unsub_tracker is not None: + if self.is_on: self.unsub_tracker() self.unsub_tracker = None - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def flux_update(self, utcnow=None): + async def async_flux_update(self, utcnow=None): """Update all the lights using flux.""" if utcnow is None: utcnow = dt_utcnow() @@ -258,22 +260,23 @@ class FluxSwitch(SwitchDevice): if self._disable_brightness_adjust: brightness = None if self._mode == MODE_XY: - set_lights_xy(self.hass, self._lights, x_val, - y_val, brightness, self._transition) + await async_set_lights_xy(self.hass, self._lights, x_val, + y_val, brightness, self._transition) _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%% " "of %s cycle complete at %s", x_val, y_val, brightness, round( percentage_complete * 100), time_state, now) elif self._mode == MODE_RGB: - set_lights_rgb(self.hass, self._lights, rgb, self._transition) + await async_set_lights_rgb(self.hass, self._lights, rgb, + self._transition) _LOGGER.info("Lights updated to rgb:%s, %s%% " "of %s cycle complete at %s", rgb, round(percentage_complete * 100), time_state, now) else: # Convert to mired and clamp to allowed values mired = color_temperature_kelvin_to_mired(temp) - set_lights_temp(self.hass, self._lights, mired, brightness, - self._transition) + await async_set_lights_temp(self.hass, self._lights, mired, + brightness, self._transition) _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%% " "of %s cycle complete at %s", mired, brightness, round(percentage_complete * 100), time_state, now) diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py index 374e59aa77b..6333375b560 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/switch/homekit_controller.py @@ -12,6 +12,8 @@ from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['homekit_controller'] +OUTLET_IN_USE = "outlet_in_use" + _LOGGER = logging.getLogger(__name__) @@ -29,6 +31,7 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): """Initialise the switch.""" super().__init__(*args) self._on = None + self._outlet_in_use = None def update_characteristics(self, characteristics): """Synchronise the switch state with Home Assistant.""" @@ -42,6 +45,7 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): self._on = characteristic['value'] elif ctype == "outlet-in-use": self._chars['outlet-in-use'] = characteristic['iid'] + self._outlet_in_use = characteristic['value'] @property def is_on(self): @@ -62,3 +66,11 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): 'iid': self._chars['on'], 'value': False}] self.put_characteristics(characteristics) + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + if self._outlet_in_use is not None: + return { + OUTLET_IN_USE: self._outlet_in_use, + } diff --git a/homeassistant/components/switch/lupusec.py b/homeassistant/components/switch/lupusec.py new file mode 100644 index 00000000000..35744160f24 --- /dev/null +++ b/homeassistant/components/switch/lupusec.py @@ -0,0 +1,53 @@ +""" +This component provides HA switch support for Lupusec Security System. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.lupusec/ +""" +import logging +from datetime import timedelta + +from homeassistant.components.lupusec import (LupusecDevice, + DOMAIN as LUPUSEC_DOMAIN) +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['lupusec'] + +SCAN_INTERVAL = timedelta(seconds=2) + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Lupusec switch devices.""" + if discovery_info is None: + return + + import lupupy.constants as CONST + + data = hass.data[LUPUSEC_DOMAIN] + + devices = [] + + for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH): + + devices.append(LupusecSwitch(data, device)) + + add_entities(devices) + + +class LupusecSwitch(LupusecDevice, SwitchDevice): + """Representation of a Lupusec switch.""" + + def turn_on(self, **kwargs): + """Turn on the device.""" + self._device.switch_on() + + def turn_off(self, **kwargs): + """Turn off the device.""" + self._device.switch_off() + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.is_on diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 633a3e50a09..b48cc0a1e14 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -7,8 +7,8 @@ https://home-assistant.io/components/switch.rainmachine/ import logging from homeassistant.components.rainmachine import ( - CONF_ZONE_RUN_TIME, DATA_RAINMACHINE, DEFAULT_ZONE_RUN, - PROGRAM_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, RainMachineEntity) + DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, + ZONE_UPDATE_TOPIC, RainMachineEntity) from homeassistant.const import ATTR_ID from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback @@ -101,15 +101,13 @@ VEGETATION_MAP = { async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): - """Set up the RainMachine Switch platform.""" - if discovery_info is None: - return + """Set up RainMachine switches sensor based on the old way.""" + pass - _LOGGER.debug('Config received: %s', discovery_info) - zone_run_time = discovery_info.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) - - rainmachine = hass.data[DATA_RAINMACHINE] +async def async_setup_entry(hass, entry, async_add_entities): + """Set up RainMachine switches based on a config entry.""" + rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id] entities = [] @@ -127,7 +125,9 @@ async def async_setup_platform( continue _LOGGER.debug('Adding zone: %s', zone) - entities.append(RainMachineZone(rainmachine, zone, zone_run_time)) + entities.append( + RainMachineZone( + rainmachine, zone, rainmachine.default_zone_runtime)) async_add_entities(entities, True) @@ -186,8 +186,8 @@ class RainMachineProgram(RainMachineSwitch): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated)) async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" @@ -251,10 +251,10 @@ class RainMachineZone(RainMachineSwitch): async def async_added_to_hass(self): """Register callbacks.""" - async_dispatcher_connect( - self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) - async_dispatcher_connect( - self.hass, ZONE_UPDATE_TOPIC, self._program_updated) + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated)) + self._dispatcher_handlers.append(async_dispatcher_connect( + self.hass, ZONE_UPDATE_TOPIC, self._program_updated)) async def async_turn_off(self, **kwargs) -> None: """Turn the zone off.""" diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index 7f00964cd20..e2ca3accdc9 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.2'] +REQUIREMENTS = ['pySwitchmate==0.4.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index d55b2301745..7e11f986b92 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -38,7 +38,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 'zimi.powerstrip.v2', 'chuangmi.plug.m1', 'chuangmi.plug.v2', - 'chuangmi.plug.v3']), + 'chuangmi.plug.v3', + ]), }) ATTR_POWER = 'power' @@ -247,7 +248,7 @@ class XiaomiPlugGenericSwitch(SwitchDevice): """Call a plug command handling error messages.""" from miio import DeviceException try: - result = await self.hass.async_add_job( + result = await self.hass.async_add_executor_job( partial(func, *args, **kwargs)) _LOGGER.debug("Response received from plug: %s", result) @@ -290,7 +291,7 @@ class XiaomiPlugGenericSwitch(SwitchDevice): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -366,7 +367,7 @@ class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True @@ -463,7 +464,7 @@ class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch): return try: - state = await self.hass.async_add_job(self._plug.status) + state = await self.hass.async_add_executor_job(self._plug.status) _LOGGER.debug("Got new state: %s", state) self._available = True diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 8022902c580..2545417e033 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.7.2'] +REQUIREMENTS = ['pyTibber==0.8.2'] DOMAIN = 'tibber' @@ -47,6 +47,9 @@ async def async_setup(hass, config): await tibber_connection.update_info() except (asyncio.TimeoutError, aiohttp.ClientError): return False + except tibber.InvalidLogin as exp: + _LOGGER.error("Failed to login. %s", exp) + return False for component in ['sensor', 'notify']: discovery.load_platform(hass, component, DOMAIN, diff --git a/homeassistant/components/toon.py b/homeassistant/components/toon.py index cfd0d297d54..01f170f0b31 100644 --- a/homeassistant/components/toon.py +++ b/homeassistant/components/toon.py @@ -4,17 +4,17 @@ Toon van Eneco Support. For more details about this component, please refer to the documentation at https://home-assistant.io/components/toon/ """ -import logging from datetime import datetime, timedelta +import logging import voluptuous as vol +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME import homeassistant.helpers.config_validation as cv -from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -REQUIREMENTS = ['toonlib==1.0.2'] +REQUIREMENTS = ['toonlib==1.1.3'] _LOGGER = logging.getLogger(__name__) @@ -62,8 +62,8 @@ def setup(hass, config): class ToonDataStore: """An object to store the Toon data.""" - def __init__(self, username, password, gas=DEFAULT_GAS, - solar=DEFAULT_SOLAR): + def __init__( + self, username, password, gas=DEFAULT_GAS, solar=DEFAULT_SOLAR): """Initialize Toon.""" from toonlib import Toon diff --git a/homeassistant/components/tplink_lte.py b/homeassistant/components/tplink_lte.py new file mode 100644 index 00000000000..17288a881aa --- /dev/null +++ b/homeassistant/components/tplink_lte.py @@ -0,0 +1,150 @@ +""" +Support for TP-Link LTE modems. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tplink_lte/ +""" +import asyncio +import logging + +import aiohttp +import attr +import voluptuous as vol + +from homeassistant.components.notify import ATTR_TARGET +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +REQUIREMENTS = ['tp-connected==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'tplink_lte' +DATA_KEY = 'tplink_lte' + +CONF_NOTIFY = "notify" + +_NOTIFY_SCHEMA = vol.All(vol.Schema({ + vol.Optional(CONF_NAME): cv.string, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), +})) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NOTIFY): + vol.All(cv.ensure_list, [_NOTIFY_SCHEMA]), + })]) +}, extra=vol.ALLOW_EXTRA) + + +@attr.s +class ModemData: + """Class for modem state.""" + + host = attr.ib() + modem = attr.ib() + + connected = attr.ib(init=False, default=True) + + +@attr.s +class LTEData: + """Shared state.""" + + websession = attr.ib() + modem_data = attr.ib(init=False, factory=dict) + + def get_modem_data(self, config): + """Get the requested or the only modem_data value.""" + if CONF_HOST in config: + return self.modem_data.get(config[CONF_HOST]) + if len(self.modem_data) == 1: + return next(iter(self.modem_data.values())) + + return None + + +async def async_setup(hass, config): + """Set up TP-Link LTE component.""" + if DATA_KEY not in hass.data: + websession = async_create_clientsession( + hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + hass.data[DATA_KEY] = LTEData(websession) + + domain_config = config.get(DOMAIN, []) + + tasks = [_setup_lte(hass, conf) for conf in domain_config] + if tasks: + await asyncio.wait(tasks) + + for conf in domain_config: + for notify_conf in conf.get(CONF_NOTIFY, []): + hass.async_create_task(discovery.async_load_platform( + hass, 'notify', DOMAIN, notify_conf, config)) + + return True + + +async def _setup_lte(hass, lte_config, delay=0): + """Set up a TP-Link LTE modem.""" + import tp_connected + + host = lte_config[CONF_HOST] + password = lte_config[CONF_PASSWORD] + + websession = hass.data[DATA_KEY].websession + modem = tp_connected.Modem(hostname=host, websession=websession) + + modem_data = ModemData(host, modem) + + try: + await _login(hass, modem_data, password) + except tp_connected.Error: + retry_task = hass.loop.create_task( + _retry_login(hass, modem_data, password)) + + @callback + def cleanup_retry(event): + """Clean up retry task resources.""" + if not retry_task.done(): + retry_task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry) + + +async def _login(hass, modem_data, password): + """Log in and complete setup.""" + await modem_data.modem.login(password=password) + modem_data.connected = True + hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + + async def cleanup(event): + """Clean up resources.""" + await modem_data.modem.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) + + +async def _retry_login(hass, modem_data, password): + """Sleep and retry setup.""" + import tp_connected + + _LOGGER.warning( + "Could not connect to %s. Will keep trying.", modem_data.host) + + modem_data.connected = False + delay = 15 + + while not modem_data.connected: + await asyncio.sleep(delay) + + try: + await _login(hass, modem_data, password) + _LOGGER.warning("Connected to %s", modem_data.host) + except tp_connected.Error: + delay = min(2*delay, 300) diff --git a/homeassistant/components/tradfri/.translations/cs.json b/homeassistant/components/tradfri/.translations/cs.json new file mode 100644 index 00000000000..97a0e25d754 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/cs.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Bridge je ji\u017e nakonfigurov\u00e1n" + }, + "error": { + "cannot_connect": "Nelze se p\u0159ipojit k br\u00e1n\u011b.", + "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el" + }, + "step": { + "auth": { + "data": { + "host": "Hostitel", + "security_code": "Bezpe\u010dnostn\u00ed k\u00f3d" + }, + "description": "Bezpe\u010dnostn\u00ed k\u00f3d naleznete na zadn\u00ed stran\u011b za\u0159\u00edzen\u00ed.", + "title": "Zadejte bezpe\u010dnostn\u00ed k\u00f3d" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/es.json b/homeassistant/components/tradfri/.translations/es.json new file mode 100644 index 00000000000..991832c9053 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "El puente ya esta configurado" + }, + "error": { + "cannot_connect": "No se puede conectar a la puerta de enlace.", + "invalid_key": "No se pudo registrar con la clave proporcionada. Si esto sigue sucediendo, intente reiniciar la puerta de enlace.", + "timeout": "Tiempo de espera agotado validando el c\u00f3digo." + }, + "step": { + "auth": { + "data": { + "host": "Host", + "security_code": "C\u00f3digo de seguridad" + }, + "description": "Puede encontrar el c\u00f3digo de seguridad en la parte posterior de su puerta de enlace.", + "title": "Introduzca el c\u00f3digo de seguridad" + } + }, + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/it.json b/homeassistant/components/tradfri/.translations/it.json new file mode 100644 index 00000000000..3d5101bbce8 --- /dev/null +++ b/homeassistant/components/tradfri/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IKEA TR\u00c5DFRI" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/cs.json b/homeassistant/components/twilio/.translations/cs.json new file mode 100644 index 00000000000..d484ede413e --- /dev/null +++ b/homeassistant/components/twilio/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Twilio.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, mus\u00edte nastavit [Webhooks s Twilio]({twilio_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: application/x-www-form-urlencoded \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu Twilio?", + "title": "Nastaven\u00ed Twilio Webhook" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/de.json b/homeassistant/components/twilio/.translations/de.json new file mode 100644 index 00000000000..86e5d9051b3 --- /dev/null +++ b/homeassistant/components/twilio/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/es.json b/homeassistant/components/twilio/.translations/es.json new file mode 100644 index 00000000000..7927ce63e7f --- /dev/null +++ b/homeassistant/components/twilio/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Twilio.", + "one_instance_allowed": "S\u00f3lo se necesita una sola instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks en Twilio] ( {twilio_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application / x-www-form-urlencoded \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?" + } + }, + "title": "Twilio" + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json index a053bf372a5..fc8b5c08261 100644 --- a/homeassistant/components/twilio/.translations/nl.json +++ b/homeassistant/components/twilio/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig." }, "step": { diff --git a/homeassistant/components/twilio/.translations/zh-Hans.json b/homeassistant/components/twilio/.translations/zh-Hans.json new file mode 100644 index 00000000000..e108fe12498 --- /dev/null +++ b/homeassistant/components/twilio/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Twilio \u7684 Webhook]({twilio_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py index c28f56a4b6c..9fcba4da817 100644 --- a/homeassistant/components/twilio/__init__.py +++ b/homeassistant/components/twilio/__init__.py @@ -56,7 +56,7 @@ async def handle_webhook(hass, webhook_id, request): async def async_setup_entry(hass, entry): """Configure based on config entry.""" hass.components.webhook.async_register( - entry.data[CONF_WEBHOOK_ID], handle_webhook) + DOMAIN, 'Twilio', entry.data[CONF_WEBHOOK_ID], handle_webhook) return True diff --git a/homeassistant/components/unifi/.translations/cs.json b/homeassistant/components/unifi/.translations/cs.json new file mode 100644 index 00000000000..95ba46597da --- /dev/null +++ b/homeassistant/components/unifi/.translations/cs.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "user_privilege": "U\u017eivatel mus\u00ed b\u00fdt spr\u00e1vcem" + }, + "error": { + "faulty_credentials": "Chybn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje", + "service_unavailable": "Slu\u017eba nen\u00ed dostupn\u00e1" + }, + "step": { + "user": { + "data": { + "host": "Hostitel", + "password": "Heslo", + "port": "Port", + "site": "ID s\u00edt\u011b", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no", + "verify_ssl": "\u0158adi\u010d pou\u017e\u00edv\u00e1 spr\u00e1vn\u00fd certifik\u00e1t" + }, + "title": "Nastaven\u00ed UniFi \u0159adi\u010de" + } + }, + "title": "UniFi \u0159adi\u010d" + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json new file mode 100644 index 00000000000..346c1937355 --- /dev/null +++ b/homeassistant/components/unifi/.translations/de.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "user_privilege": "Der Benutzer muss Administrator sein" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/zh-Hans.json b/homeassistant/components/unifi/.translations/zh-Hans.json new file mode 100644 index 00000000000..c8796536e2f --- /dev/null +++ b/homeassistant/components/unifi/.translations/zh-Hans.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "\u63a7\u5236\u5668\u7ad9\u70b9\u5df2\u914d\u7f6e\u5b8c\u6210", + "user_privilege": "\u7528\u6237\u987b\u4e3a\u7ba1\u7406\u5458" + }, + "error": { + "service_unavailable": "\u6ca1\u6709\u53ef\u7528\u7684\u670d\u52a1" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "site": "\u7ad9\u70b9 ID", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u4f7f\u7528\u6b63\u786e\u8bc1\u4e66\u7684\u63a7\u5236\u5668" + }, + "title": "\u914d\u7f6e UniFi \u63a7\u5236\u5668" + } + }, + "title": "UniFi \u63a7\u5236\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/cs.json b/homeassistant/components/upnp/.translations/cs.json index 24a725d1af6..58de4963000 100644 --- a/homeassistant/components/upnp/.translations/cs.json +++ b/homeassistant/components/upnp/.translations/cs.json @@ -1,8 +1,19 @@ { "config": { + "abort": { + "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" + }, "step": { + "init": { + "title": "UPnP/IGD" + }, "user": { "data": { + "enable_port_mapping": "Povolit mapov\u00e1n\u00ed port\u016f pro Home Assistant", + "enable_sensors": "P\u0159idejte dopravn\u00ed senzory", "igd": "UPnP/IGD" }, "title": "Mo\u017enosti konfigurace pro UPnP/IGD" diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json index e4cabf4cd50..652ff87d9d4 100644 --- a/homeassistant/components/upnp/.translations/es.json +++ b/homeassistant/components/upnp/.translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD ya est\u00e1 configurado", + "incomplete_device": "Ignorando el dispositivo UPnP incompleto", "no_devices_discovered": "No se descubrieron UPnP / IGDs", "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos" }, diff --git a/homeassistant/components/upnp/.translations/lb.json b/homeassistant/components/upnp/.translations/lb.json index 1d13492a487..183144afb53 100644 --- a/homeassistant/components/upnp/.translations/lb.json +++ b/homeassistant/components/upnp/.translations/lb.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD ass scho konfigur\u00e9iert", + "incomplete_device": "Ignor\u00e9iert onvollst\u00e4nnegen UPnP-Apparat", "no_devices_discovered": "Keng UPnP/IGDs entdeckt", "no_sensors_or_port_mapping": "Aktiv\u00e9ier op mannst Sensoren oder Port Mapping" }, diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json index 647eb647f24..c6939f9a0a7 100644 --- a/homeassistant/components/upnp/.translations/nl.json +++ b/homeassistant/components/upnp/.translations/nl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD is al geconfigureerd", + "incomplete_device": "Onvolledig UPnP-apparaat negeren", "no_devices_discovered": "Geen UPnP / IGD's ontdekt", "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in" }, diff --git a/homeassistant/components/upnp/.translations/no.json b/homeassistant/components/upnp/.translations/no.json index a0c4c23f9c4..50b661627e3 100644 --- a/homeassistant/components/upnp/.translations/no.json +++ b/homeassistant/components/upnp/.translations/no.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP / IGD er allerede konfigurert", + "incomplete_device": "Ignorerer ufullstendig UPnP-enhet", "no_devices_discovered": "Ingen UPnP / IGDs oppdaget", "no_sensors_or_port_mapping": "Aktiver minst sensorer eller port mapping" }, diff --git a/homeassistant/components/upnp/.translations/pl.json b/homeassistant/components/upnp/.translations/pl.json index e47a25b9d93..d01946cb6e2 100644 --- a/homeassistant/components/upnp/.translations/pl.json +++ b/homeassistant/components/upnp/.translations/pl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane", + "incomplete_device": "Ignorowanie niekompletnego urz\u0105dzenia UPnP", "no_devices_discovered": "Nie wykryto urz\u0105dze\u0144 UPnP/IGD", "no_sensors_or_port_mapping": "W\u0142\u0105cz przynajmniej sensory lub mapowanie port\u00f3w" }, diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json index 899a5def479..99d056a7d78 100644 --- a/homeassistant/components/upnp/.translations/pt.json +++ b/homeassistant/components/upnp/.translations/pt.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado", + "incomplete_device": "Dispositivos UPnP incompletos ignorados", "no_devices_discovered": "Nenhum UPnP/IGDs descoberto", "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta" }, diff --git a/homeassistant/components/upnp/.translations/sl.json b/homeassistant/components/upnp/.translations/sl.json index 20debe7f09a..f7052051192 100644 --- a/homeassistant/components/upnp/.translations/sl.json +++ b/homeassistant/components/upnp/.translations/sl.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD je \u017ee konfiguriran", + "incomplete_device": "Ignoriranje nepopolnih UPnP naprav", "no_devices_discovered": "Ni odkritih UPnP/IGD naprav", "no_sensors_or_port_mapping": "Omogo\u010dite vsaj senzorje ali preslikavo vrat (port mapping)" }, diff --git a/homeassistant/components/upnp/.translations/zh-Hans.json b/homeassistant/components/upnp/.translations/zh-Hans.json index c4962ba1c4b..b16172e97d7 100644 --- a/homeassistant/components/upnp/.translations/zh-Hans.json +++ b/homeassistant/components/upnp/.translations/zh-Hans.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "UPnP/IGD \u5df2\u914d\u7f6e\u5b8c\u6210", + "incomplete_device": "\u5ffd\u7565\u4e0d\u5b8c\u6574\u7684 UPnP \u8bbe\u5907", "no_devices_discovered": "\u672a\u53d1\u73b0 UPnP/IGD", "no_sensors_or_port_mapping": "\u81f3\u5c11\u542f\u7528\u4f20\u611f\u5668\u6216\u7aef\u53e3\u6620\u5c04" }, diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index e69943ae8b2..925ca561eb9 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -29,7 +29,7 @@ from .config_flow import async_ensure_domain_data from .device import Device -REQUIREMENTS = ['async-upnp-client==0.13.0'] +REQUIREMENTS = ['async-upnp-client==0.13.2'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 2e25af36b11..a491b69ca2f 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -266,7 +266,8 @@ class MiroboVacuum(StateVacuumDevice): """Call a vacuum command handling error messages.""" from miio import DeviceException try: - await self.hass.async_add_job(partial(func, *args, **kwargs)) + await self.hass.async_add_executor_job( + partial(func, *args, **kwargs)) return True except DeviceException as exc: _LOGGER.error(mask_error, exc) diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index a7b385297a8..15ca8584a4e 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -48,6 +48,7 @@ async def async_setup(hass, config): discovery_info = { 'switch': [], 'binary_sensor': [], + 'climate': [], 'sensor': [] } for module in modules: @@ -60,6 +61,8 @@ async def async_setup(hass, config): )) load_platform(hass, 'switch', DOMAIN, discovery_info['switch'], config) + load_platform(hass, 'climate', DOMAIN, + discovery_info['climate'], config) load_platform(hass, 'binary_sensor', DOMAIN, discovery_info['binary_sensor'], config) load_platform(hass, 'sensor', DOMAIN, diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 2c8c34fa67d..2f2fa194846 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['vsure==1.5.0', 'jsonpath==0.75'] +REQUIREMENTS = ['vsure==1.5.2', 'jsonpath==0.75'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/w800rf32.py b/homeassistant/components/w800rf32.py new file mode 100644 index 00000000000..4b237272546 --- /dev/null +++ b/homeassistant/components/w800rf32.py @@ -0,0 +1,67 @@ +""" +Support for w800rf32 components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/w800rf32/ + +""" +import logging + +import voluptuous as vol + +from homeassistant.const import (CONF_DEVICE, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import (dispatcher_send) + +REQUIREMENTS = ['pyW800rf32==0.1'] + +DOMAIN = 'w800rf32' +DATA_W800RF32 = 'data_w800rf32' +W800RF32_DEVICE = 'w800rf32_{}' + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_DEVICE): cv.string + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the w800rf32 component.""" + # Try to load the W800rf32 module. + import W800rf32 as w800 + + # Declare the Handle event + def handle_receive(event): + """Handle received messages from w800rf32 gateway.""" + # Log event + if not event.device: + return + _LOGGER.debug("Receive W800rf32 event in handle_receive") + + # Get device_type from device_id in hass.data + device_id = event.device.lower() + signal = W800RF32_DEVICE.format(device_id) + dispatcher_send(hass, signal, event) + + # device --> /dev/ttyUSB0 + device = config[DOMAIN][CONF_DEVICE] + w800_object = w800.Connect(device, None) + + def _start_w800rf32(event): + w800_object.event_callback = handle_receive + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_w800rf32) + + def _shutdown_w800rf32(event): + """Close connection with w800rf32.""" + w800_object.close_connection() + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_w800rf32) + + hass.data[DATA_W800RF32] = w800_object + + return True diff --git a/homeassistant/components/weather/ecobee.py b/homeassistant/components/weather/ecobee.py index 5a191aa7af1..7382e5c1815 100644 --- a/homeassistant/components/weather/ecobee.py +++ b/homeassistant/components/weather/ecobee.py @@ -5,19 +5,18 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/weather.ecobee/ """ from datetime import datetime + from homeassistant.components import ecobee from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) -from homeassistant.const import (TEMP_FAHRENHEIT) - + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_SPEED, WeatherEntity) +from homeassistant.const import TEMP_FAHRENHEIT DEPENDENCIES = ['ecobee'] ATTR_FORECAST_TEMP_HIGH = 'temphigh' ATTR_FORECAST_PRESSURE = 'pressure' ATTR_FORECAST_VISIBILITY = 'visibility' -ATTR_FORECAST_WIND_SPEED = 'windspeed' ATTR_FORECAST_HUMIDITY = 'humidity' MISSING_DATA = -5002 @@ -41,7 +40,7 @@ class EcobeeWeather(WeatherEntity): """Representation of Ecobee weather data.""" def __init__(self, name, index): - """Initialize the sensor.""" + """Initialize the Ecobee weather platform.""" self._name = name self._index = index self.weather = None diff --git a/homeassistant/components/weather/smhi.py b/homeassistant/components/weather/smhi.py index 41ac1571339..c686b5c90e9 100644 --- a/homeassistant/components/weather/smhi.py +++ b/homeassistant/components/weather/smhi.py @@ -1,33 +1,28 @@ -"""Support for the Swedish weather institute weather service. +""" +Support for the Swedish weather institute weather service. For more details about this platform, please refer to the documentation https://home-assistant.io/components/weather.smhi/ """ - - import asyncio -import logging from datetime import timedelta +import logging from typing import Dict, List import aiohttp import async_timeout +from homeassistant.components.smhi.const import ( + ATTR_SMHI_CLOUDINESS, ENTITY_ID_SENSOR_FORMAT) +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, CONF_LONGITUDE, - CONF_NAME, TEMP_CELSIUS) + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.util import dt, slugify, Throttle - -from homeassistant.components.weather import ( - WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, - ATTR_FORECAST_PRECIPITATION) - -from homeassistant.components.smhi.const import ( - ENTITY_ID_SENSOR_FORMAT, ATTR_SMHI_CLOUDINESS) +from homeassistant.util import Throttle, dt, slugify DEPENDENCIES = ['smhi'] @@ -51,15 +46,14 @@ CONDITION_CLASSES = { 'exceptional': [], } - # 5 minutes between retrying connect to API again RETRY_TIMEOUT = 5*60 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): """Old way of setting up components. Can only be called when a user accidentally mentions smhi in the @@ -68,18 +62,18 @@ async def async_setup_platform(hass, config, async_add_entities, pass -async def async_setup_entry(hass: HomeAssistant, - config_entry: ConfigEntry, - config_entries) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: ConfigEntry, + config_entries) -> bool: """Add a weather entity from map location.""" location = config_entry.data name = slugify(location[CONF_NAME]) session = aiohttp_client.async_get_clientsession(hass) - entity = SmhiWeather(location[CONF_NAME], location[CONF_LATITUDE], - location[CONF_LONGITUDE], - session=session) + entity = SmhiWeather( + location[CONF_NAME], location[CONF_LATITUDE], location[CONF_LONGITUDE], + session=session) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) config_entries([entity], True) @@ -100,8 +94,7 @@ class SmhiWeather(WeatherEntity): self._longitude = longitude self._forecasts = None self._fail_count = 0 - self._smhi_api = Smhi(self._longitude, self._latitude, - session=session) + self._smhi_api = Smhi(self._longitude, self._latitude, session=session) @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: @@ -109,6 +102,7 @@ class SmhiWeather(WeatherEntity): from smhi.smhi_lib import SmhiForecastException def fail(): + """Postpone updates.""" self._fail_count += 1 if self._fail_count < 3: self.hass.helpers.event.async_call_later( @@ -120,8 +114,8 @@ class SmhiWeather(WeatherEntity): self._fail_count = 0 except (asyncio.TimeoutError, SmhiForecastException): - _LOGGER.error("Failed to connect to SMHI API, " - "retry in 5 minutes") + _LOGGER.error( + "Failed to connect to SMHI API, retry in 5 minutes") fail() async def retry_update(self): @@ -161,7 +155,7 @@ class SmhiWeather(WeatherEntity): """Return the wind speed.""" if self._forecasts is not None: # Convert from m/s to km/h - return round(self._forecasts[0].wind_speed*18/5) + return round(self._forecasts[0].wind_speed * 18 / 5) return None @property @@ -221,17 +215,13 @@ class SmhiWeather(WeatherEntity): # Only get mid day forecasts if forecast.valid_time.hour == 12: data.append({ - ATTR_FORECAST_TIME: - dt.as_local(forecast.valid_time), - ATTR_FORECAST_TEMP: - forecast.temperature_max, - ATTR_FORECAST_TEMP_LOW: - forecast.temperature_min, + ATTR_FORECAST_TIME: dt.as_local(forecast.valid_time), + ATTR_FORECAST_TEMP: forecast.temperature_max, + ATTR_FORECAST_TEMP_LOW: forecast.temperature_min, ATTR_FORECAST_PRECIPITATION: round(forecast.mean_precipitation*24), - ATTR_FORECAST_CONDITION: - condition - }) + ATTR_FORECAST_CONDITION: condition, + }) return data diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index 2a4c3f973f2..ad23ba6f544 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -6,10 +6,12 @@ https://home-assistant.io/components/webhook/ import logging from aiohttp.web import Response +import voluptuous as vol from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.auth.util import generate_secret +from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView DOMAIN = 'webhook' @@ -17,16 +19,26 @@ DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) +WS_TYPE_LIST = 'webhook/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + + @callback @bind_hass -def async_register(hass, webhook_id, handler): +def async_register(hass, domain, name, webhook_id, handler): """Register a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) if webhook_id in handlers: raise ValueError('Handler is already defined!') - handlers[webhook_id] = handler + handlers[webhook_id] = { + 'domain': domain, + 'name': name, + 'handler': handler + } @callback @@ -53,6 +65,10 @@ def async_generate_url(hass, webhook_id): async def async_setup(hass, config): """Initialize the webhook component.""" hass.http.register_view(WebhookView) + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) return True @@ -67,19 +83,33 @@ class WebhookView(HomeAssistantView): """Handle webhook call.""" hass = request.app['hass'] handlers = hass.data.setdefault(DOMAIN, {}) - handler = handlers.get(webhook_id) + webhook = handlers.get(webhook_id) # Always respond successfully to not give away if a hook exists or not. - if handler is None: + if webhook is None: _LOGGER.warning( 'Received message for unregistered webhook %s', webhook_id) return Response(status=200) try: - response = await handler(hass, webhook_id, request) + response = await webhook['handler'](hass, webhook_id, request) if response is None: response = Response(status=200) return response except Exception: # pylint: disable=broad-except _LOGGER.exception("Error processing webhook %s", webhook_id) return Response(status=200) + + +@callback +def websocket_list(hass, connection, msg): + """Return a list of webhooks.""" + handlers = hass.data.setdefault(DOMAIN, {}) + result = [{ + 'webhook_id': webhook_id, + 'domain': info['domain'], + 'name': info['name'], + } for webhook_id, info in handlers.items()] + + connection.send_message( + websocket_api.result_message(msg['id'], result)) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 3804b019ad5..93760405e08 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.28'] +REQUIREMENTS = ['pywemo==0.4.29'] DOMAIN = 'wemo' diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index 3db044c4d1b..a94f8c3bdf2 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -687,6 +687,11 @@ class WinkDevice(Entity): """Return the name of the device.""" return self.wink.name() + @property + def unique_id(self): + """Return the unique id of the Wink device.""" + return self.wink.object_id() + @property def available(self): """Return true if connection == True.""" diff --git a/homeassistant/components/wirelesstag.py b/homeassistant/components/wirelesstag.py index f2832100066..77b4c48b41b 100644 --- a/homeassistant/components/wirelesstag.py +++ b/homeassistant/components/wirelesstag.py @@ -271,7 +271,7 @@ class WirelessTagBaseSensor(Entity): def device_state_attributes(self): """Return the state attributes.""" return { - ATTR_BATTERY_LEVEL: self._tag.battery_remaining, + ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining*100), ATTR_VOLTAGE: '{:.2f}V'.format(self._tag.battery_volts), ATTR_TAG_SIGNAL_STRENGTH: '{}dBm'.format( self._tag.signal_strength), diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 0b3e926fadc..88dee57aa70 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -22,7 +22,7 @@ def populate_data(): zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', zha.DeviceType.SMART_PLUG: 'switch', - + zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: 'light', zha.DeviceType.ON_OFF_LIGHT: 'light', zha.DeviceType.DIMMABLE_LIGHT: 'light', zha.DeviceType.COLOR_DIMMABLE_LIGHT: 'light', @@ -47,6 +47,7 @@ def populate_data(): SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', + zcl.clusters.general.LevelControl: 'light', zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', zcl.clusters.measurement.PressureMeasurement: 'sensor', diff --git a/homeassistant/components/zone/.translations/es.json b/homeassistant/components/zone/.translations/es.json new file mode 100644 index 00000000000..7a0f6c967c2 --- /dev/null +++ b/homeassistant/components/zone/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "El nombre ya existe" + }, + "step": { + "init": { + "data": { + "icon": "Icono", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre", + "passive": "Pasivo", + "radius": "Radio" + }, + "title": "Definir par\u00e1metros de la zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json index 8c287d9a539..39947080d18 100644 --- a/homeassistant/components/zwave/.translations/es.json +++ b/homeassistant/components/zwave/.translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Z-Wave ya est\u00e1 configurado", "one_instance_only": "El componente solo admite una instancia de Z-Wave" }, "error": { diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 87a955f6f20..dd0b36020a4 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -67,7 +67,7 @@ DEFAULT_CONF_REFRESH_VALUE = False DEFAULT_CONF_REFRESH_DELAY = 5 SUPPORTED_PLATFORMS = ['binary_sensor', 'climate', 'cover', 'fan', - 'light', 'sensor', 'switch'] + 'lock', 'light', 'sensor', 'switch'] RENAME_NODE_SCHEMA = vol.Schema({ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index eaef97011fc..2325f35822f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -145,9 +145,13 @@ FLOWS = [ 'ios', 'lifx', 'mailgun', + 'luftdaten', 'mqtt', 'nest', 'openuv', + 'owntracks', + 'point', + 'rainmachine', 'simplisafe', 'smhi', 'sonos', diff --git a/homeassistant/const.py b/homeassistant/const.py index 9db85f12960..dc00267cdf8 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 = 82 -PATCH_VERSION = '1' +MINOR_VERSION = 83 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -449,3 +449,7 @@ WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] PRECISION_WHOLE = 1 PRECISION_HALVES = 0.5 PRECISION_TENTHS = 0.1 + +# Static list of entities that will never be exposed to +# cloud, alexa, or google_home components +CLOUD_NEVER_EXPOSED_ENTITIES = ['group.all_locks'] diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 11aa1848529..0613b7cb10c 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,24 +1,24 @@ """The exceptions used by Home Assistant.""" +from typing import Optional, Tuple, TYPE_CHECKING import jinja2 +# pylint: disable=using-constant-test +if TYPE_CHECKING: + # pylint: disable=unused-import + from .core import Context # noqa + class HomeAssistantError(Exception): """General Home Assistant exception occurred.""" - pass - class InvalidEntityFormatError(HomeAssistantError): """When an invalid formatted entity is encountered.""" - pass - class NoEntitySpecifiedError(HomeAssistantError): """When no entity is specified.""" - pass - class TemplateError(HomeAssistantError): """Error during template rendering.""" @@ -32,16 +32,29 @@ class TemplateError(HomeAssistantError): class PlatformNotReady(HomeAssistantError): """Error to indicate that platform is not ready.""" - pass - class ConfigEntryNotReady(HomeAssistantError): """Error to indicate that config entry is not ready.""" - pass - class InvalidStateError(HomeAssistantError): """When an invalid state is encountered.""" - pass + +class Unauthorized(HomeAssistantError): + """When an action is unauthorized.""" + + def __init__(self, context: Optional['Context'] = None, + user_id: Optional[str] = None, + entity_id: Optional[str] = None, + permission: Optional[Tuple[str]] = None) -> None: + """Unauthorized error.""" + super().__init__(self.__class__.__name__) + self.context = context + self.user_id = user_id + self.entity_id = entity_id + self.permission = permission + + +class UnknownUser(Unauthorized): + """When call is made with user ID that doesn't exist.""" diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 8b621b2f01c..6ed7cbb9b51 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,9 +1,10 @@ """Deprecation helpers for Home Assistant.""" import inspect import logging +from typing import Any, Callable, Dict, Optional -def deprecated_substitute(substitute_name): +def deprecated_substitute(substitute_name: str) -> Callable[..., Callable]: """Help migrate properties to new names. When a property is added to replace an older property, this decorator can @@ -11,9 +12,9 @@ def deprecated_substitute(substitute_name): If the old property is defined, its value will be used instead, and a log warning will be issued alerting the user of the impending change. """ - def decorator(func): + def decorator(func: Callable) -> Callable: """Decorate function as deprecated.""" - def func_wrapper(self): + def func_wrapper(self: Callable) -> Any: """Wrap for the original function.""" if hasattr(self, substitute_name): # If this platform is still using the old property, issue @@ -28,8 +29,7 @@ def deprecated_substitute(substitute_name): substitute_name, substitute_name, func.__name__, inspect.getfile(self.__class__)) warnings[module_name] = True - # pylint: disable=protected-access - func._deprecated_substitute_warnings = warnings + setattr(func, '_deprecated_substitute_warnings', warnings) # Return the old property return getattr(self, substitute_name) @@ -38,7 +38,8 @@ def deprecated_substitute(substitute_name): return decorator -def get_deprecated(config, new_name, old_name, default=None): +def get_deprecated(config: Dict[str, Any], new_name: str, old_name: str, + default: Optional[Any] = None) -> Optional[Any]: """Allow an old config name to be deprecated with a replacement. If the new config isn't found, but the old one is, the old value is used diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 69a3f234c22..78d15e57f38 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -38,6 +38,26 @@ class DeviceEntry: id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) +def format_mac(mac): + """Format the mac address string for entry into dev reg.""" + to_test = mac + + if len(to_test) == 17 and to_test.count(':') == 5: + return to_test.lower() + + if len(to_test) == 17 and to_test.count('-') == 5: + to_test = to_test.replace('-', '') + elif len(to_test) == 14 and to_test.count('.') == 2: + to_test = to_test.replace('.', '') + + if len(to_test) == 12: + # no : included + return ':'.join(to_test.lower()[i:i + 2] for i in range(0, 12, 2)) + + # Not sure how formatted, return original + return mac + + class DeviceRegistry: """Class to hold a registry of devices.""" @@ -71,6 +91,12 @@ class DeviceRegistry: if connections is None: connections = set() + connections = { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC + else (key, value) + for key, value in connections + } + device = self.async_get_device(identifiers, connections) if device is None: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 405861eeb75..34f9a95b3a4 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -119,7 +119,7 @@ def load_platform(hass, component, platform, discovered, hass_config): Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be fired to load the platform. The event will contain: - { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <> ATTR_PLATFORM = <> ATTR_DISCOVERED = <> } @@ -137,7 +137,7 @@ async def async_load_platform(hass, component, platform, discovered, Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be fired to load the platform. The event will contain: - { ATTR_SERVICE = LOAD_PLATFORM + '.' + <> + { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <> ATTR_PLATFORM = <> ATTR_DISCOVERED = <> } diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 136f4caa35a..a28cd3d6392 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -1,9 +1,11 @@ """Helpers for Home Assistant dispatcher & internal component/platform.""" import logging +from typing import Any, Callable from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe +from .typing import HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -11,12 +13,13 @@ DATA_DISPATCHER = 'dispatcher' @bind_hass -def dispatcher_connect(hass, signal, target): +def dispatcher_connect(hass: HomeAssistantType, signal: str, + target: Callable[..., None]) -> Callable[[], None]: """Connect a callable function to a signal.""" async_unsub = run_callback_threadsafe( hass.loop, async_dispatcher_connect, hass, signal, target).result() - def remove_dispatcher(): + def remove_dispatcher() -> None: """Remove signal listener.""" run_callback_threadsafe(hass.loop, async_unsub).result() @@ -25,7 +28,8 @@ def dispatcher_connect(hass, signal, target): @callback @bind_hass -def async_dispatcher_connect(hass, signal, target): +def async_dispatcher_connect(hass: HomeAssistantType, signal: str, + target: Callable[..., Any]) -> Callable[[], None]: """Connect a callable function to a signal. This method must be run in the event loop. @@ -39,7 +43,7 @@ def async_dispatcher_connect(hass, signal, target): hass.data[DATA_DISPATCHER][signal].append(target) @callback - def async_remove_dispatcher(): + def async_remove_dispatcher() -> None: """Remove signal listener.""" try: hass.data[DATA_DISPATCHER][signal].remove(target) @@ -53,14 +57,15 @@ def async_dispatcher_connect(hass, signal, target): @bind_hass -def dispatcher_send(hass, signal, *args): +def dispatcher_send(hass: HomeAssistantType, signal: str, *args: Any) -> None: """Send signal and data.""" hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args) @callback @bind_hass -def async_dispatcher_send(hass, signal, *args): +def async_dispatcher_send( + hass: HomeAssistantType, signal: str, *args: Any) -> None: """Send signal and data. This method must be run in the event loop. diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 5fd580a33f0..ec7b5579342 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -206,7 +206,6 @@ class EntityPlatform: return hass = self.hass - component_entities = set(hass.states.async_entity_ids(self.domain)) device_registry = await \ hass.helpers.device_registry.async_get_registry() @@ -214,8 +213,7 @@ class EntityPlatform: hass.helpers.entity_registry.async_get_registry() tasks = [ self._async_add_entity(entity, update_before_add, - component_entities, entity_registry, - device_registry) + entity_registry, device_registry) for entity in new_entities] # No entities for processing @@ -235,8 +233,7 @@ class EntityPlatform: ) async def _async_add_entity(self, entity, update_before_add, - component_entities, entity_registry, - device_registry): + entity_registry, device_registry): """Add an entity to the platform.""" if entity is None: raise ValueError('Entity cannot be None') @@ -329,25 +326,24 @@ class EntityPlatform: if self.entity_namespace is not None: suggested_object_id = '{} {}'.format(self.entity_namespace, suggested_object_id) - entity.entity_id = entity_registry.async_generate_entity_id( - self.domain, suggested_object_id) + self.domain, suggested_object_id, self.entities.keys()) # Make sure it is valid in case an entity set the value themselves if not valid_entity_id(entity.entity_id): raise HomeAssistantError( 'Invalid entity id: {}'.format(entity.entity_id)) - elif entity.entity_id in component_entities: + elif (entity.entity_id in self.entities or + entity.entity_id in self.hass.states.async_entity_ids( + self.domain)): msg = 'Entity id already exists: {}'.format(entity.entity_id) if entity.unique_id is not None: msg += '. Platform {} does not generate unique IDs'.format( self.platform_name) - raise HomeAssistantError( - msg) + raise HomeAssistantError(msg) entity_id = entity.entity_id self.entities[entity_id] = entity - component_entities.add(entity_id) entity.async_on_remove(lambda: self.entities.pop(entity_id)) if hasattr(entity, 'async_added_to_hass'): diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5adf748dc58..c40d14652ad 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -95,7 +95,8 @@ class EntityRegistry: return None @callback - def async_generate_entity_id(self, domain, suggested_object_id): + def async_generate_entity_id(self, domain, suggested_object_id, + known_object_ids=None): """Generate an entity ID that does not conflict. Conflicts checked against registered and currently existing entities. @@ -103,7 +104,8 @@ class EntityRegistry: return ensure_unique_string( '{}.{}'.format(domain, slugify(suggested_object_id)), chain(self.entities.keys(), - self.hass.states.async_entity_ids(domain)) + self.hass.states.async_entity_ids(domain), + known_object_ids if known_object_ids else []) ) @callback diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 77739f8adab..caf580ebc75 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -2,7 +2,7 @@ from collections import OrderedDict import fnmatch import re -from typing import Dict +from typing import Any, Dict, Optional, Pattern # noqa: F401 from homeassistant.core import split_entity_id @@ -10,15 +10,16 @@ from homeassistant.core import split_entity_id class EntityValues: """Class to store entity id based values.""" - def __init__(self, exact: Dict = None, domain: Dict = None, - glob: Dict = None) -> None: + def __init__(self, exact: Optional[Dict] = None, + domain: Optional[Dict] = None, + glob: Optional[Dict] = None) -> None: """Initialize an EntityConfigDict.""" - self._cache = {} + self._cache = {} # type: Dict[str, Dict] self._exact = exact self._domain = domain if glob is None: - compiled = None + compiled = None # type: Optional[Dict[Pattern[str], Any]] else: compiled = OrderedDict() for key, value in glob.items(): @@ -26,7 +27,7 @@ class EntityValues: self._glob = compiled - def get(self, entity_id): + def get(self, entity_id: str) -> Dict: """Get config for an entity id.""" if entity_id in self._cache: return self._cache[entity_id] diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 141fc912275..7db577dfdc6 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -1,4 +1,5 @@ """Helper class to implement include/exclude of entities and domains.""" +from typing import Callable, Dict, Iterable import voluptuous as vol @@ -11,14 +12,14 @@ CONF_EXCLUDE_DOMAINS = 'exclude_domains' CONF_EXCLUDE_ENTITIES = 'exclude_entities' -def _convert_filter(config): +def _convert_filter(config: Dict[str, Iterable[str]]) -> Callable[[str], bool]: filt = generate_filter( config[CONF_INCLUDE_DOMAINS], config[CONF_INCLUDE_ENTITIES], config[CONF_EXCLUDE_DOMAINS], config[CONF_EXCLUDE_ENTITIES], ) - filt.config = config + setattr(filt, 'config', config) return filt @@ -33,8 +34,10 @@ FILTER_SCHEMA = vol.All( }), _convert_filter) -def generate_filter(include_domains, include_entities, - exclude_domains, exclude_entities): +def generate_filter(include_domains: Iterable[str], + include_entities: Iterable[str], + exclude_domains: Iterable[str], + exclude_entities: Iterable[str]) -> Callable[[str], bool]: """Return a function that will filter entities based on the args.""" include_d = set(include_domains) include_e = set(include_entities) @@ -50,7 +53,7 @@ def generate_filter(include_domains, include_entities, # Case 2 - includes, no excludes - only include specified entities if have_include and not have_exclude: - def entity_filter_2(entity_id): + def entity_filter_2(entity_id: str) -> bool: """Return filter function for case 2.""" domain = split_entity_id(entity_id)[0] return (entity_id in include_e or @@ -60,7 +63,7 @@ def generate_filter(include_domains, include_entities, # Case 3 - excludes, no includes - only exclude specified entities if not have_include and have_exclude: - def entity_filter_3(entity_id): + def entity_filter_3(entity_id: str) -> bool: """Return filter function for case 3.""" domain = split_entity_id(entity_id)[0] return (entity_id not in exclude_e and @@ -75,7 +78,7 @@ def generate_filter(include_domains, include_entities, # note: if both include and exclude domains specified, # the exclude domains are ignored if include_d: - def entity_filter_4a(entity_id): + def entity_filter_4a(entity_id: str) -> bool: """Return filter function for case 4a.""" domain = split_entity_id(entity_id)[0] if domain in include_d: @@ -88,7 +91,7 @@ def generate_filter(include_domains, include_entities, # - if domain is excluded, pass if entity is included # - if domain is not excluded, pass if entity not excluded if exclude_d: - def entity_filter_4b(entity_id): + def entity_filter_4b(entity_id: str) -> bool: """Return filter function for case 4b.""" domain = split_entity_id(entity_id)[0] if domain in exclude_d: @@ -99,7 +102,7 @@ def generate_filter(include_domains, include_entities, # Case 4c - neither include or exclude domain specified # - Only pass if entity is included. Ignore entity excludes. - def entity_filter_4c(entity_id): + def entity_filter_4c(entity_id: str) -> bool: """Return filter function for case 4c.""" return entity_id in include_e diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 5e660ba7b7f..80d66f4fac8 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -120,6 +120,10 @@ class Script(): self.name, ex) break + self.last_action = action.get( + CONF_ALIAS, 'delay {}'.format(delay)) + self._log("Executing step %s" % self.last_action) + unsub = async_track_point_in_utc_time( self.hass, async_script_delay, date_util.utcnow() + delay @@ -136,6 +140,9 @@ class Script(): wait_template = action[CONF_WAIT_TEMPLATE] wait_template.hass = self.hass + self.last_action = action.get(CONF_ALIAS, 'wait template') + self._log("Executing step %s" % self.last_action) + # check if condition already okay if condition.async_template( self.hass, wait_template, variables): diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 0f394a6f153..e8068f57286 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -5,9 +5,10 @@ from os import path import voluptuous as vol +from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ATTR_ENTITY_ID import homeassistant.core as ha -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser from homeassistant.helpers import template from homeassistant.loader import get_component, bind_hass from homeassistant.util.yaml import load_yaml @@ -187,23 +188,75 @@ async def entity_service_call(hass, platforms, func, call): Calls all platforms simultaneously. """ - tasks = [] - all_entities = ATTR_ENTITY_ID not in call.data - if not all_entities: + 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) + entity_perms = user.permissions.check_entity + else: + entity_perms = None + + # Are we trying to target all entities + target_all_entities = ATTR_ENTITY_ID not in call.data + + if not target_all_entities: + # A set of entities we're trying to target. entity_ids = set( 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): data = {key: val for key, val in call.data.items() if key != ATTR_ENTITY_ID} + # If the service function is not a string, we pass the service call else: data = call + # Check the permissions + + # A list with for each platform in platforms a list of entities to call + # the service on. + platforms_entities = [] + + if entity_perms is None: + for platform in platforms: + if target_all_entities: + platforms_entities.append(list(platform.entities.values())) + else: + platforms_entities.append([ + entity for entity in platform.entities.values() + if entity.entity_id in entity_ids + ]) + + elif target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + for platform in platforms: + platforms_entities.append([ + entity for entity in platform.entities.values() + if entity_perms(entity.entity_id, POLICY_CONTROL)]) + + else: + for platform in platforms: + platform_entities = [] + for entity in platform.entities.values(): + if entity.entity_id not in entity_ids: + continue + + if not entity_perms(entity.entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity.entity_id, + permission=POLICY_CONTROL + ) + + platform_entities.append(entity) + + platforms_entities.append(platform_entities) + tasks = [ - _handle_service_platform_call(func, data, [ - entity for entity in platform.entities.values() - if all_entities or entity.entity_id in entity_ids - ], call.context) for platform in platforms + _handle_service_platform_call(func, data, entities, call.context) + for platform, entities in zip(platforms, platforms_entities) ] if tasks: diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 7496388fb52..ffb6197ab66 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -2,6 +2,7 @@ import logging import signal import sys +from types import FrameType from homeassistant.core import callback, HomeAssistant from homeassistant.const import RESTART_EXIT_CODE @@ -16,7 +17,7 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" if sys.platform != 'win32': @callback - def async_signal_handle(exit_code): + def async_signal_handle(exit_code: int) -> None: """Wrap signal handling. * queue call to shutdown task @@ -49,7 +50,7 @@ def async_register_signal_handling(hass: HomeAssistant) -> None: old_sigint = None @callback - def async_signal_handle(exit_code, frame): + def async_signal_handle(exit_code: int, frame: FrameType) -> None: """Wrap signal handling. * queue call to shutdown task diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 99ea1bad11f..049359a7313 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -1,23 +1,28 @@ """Helpers for sun events.""" import datetime +from typing import Optional, Union, TYPE_CHECKING from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET from homeassistant.core import callback from homeassistant.util import dt as dt_util from homeassistant.loader import bind_hass +from .typing import HomeAssistantType + +if TYPE_CHECKING: + import astral # pylint: disable=unused-import DATA_LOCATION_CACHE = 'astral_location_cache' @callback @bind_hass -def get_astral_location(hass): +def get_astral_location(hass: HomeAssistantType) -> 'astral.Location': """Get an astral location for the current Home Assistant configuration.""" from astral import Location latitude = hass.config.latitude longitude = hass.config.longitude - timezone = hass.config.time_zone.zone + timezone = str(hass.config.time_zone) elevation = hass.config.elevation info = ('', '', latitude, longitude, timezone, elevation) @@ -33,9 +38,12 @@ def get_astral_location(hass): @callback @bind_hass -def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): +def get_astral_event_next( + hass: HomeAssistantType, event: str, + utc_point_in_time: Optional[datetime.datetime] = None, + offset: Optional[datetime.timedelta] = None) -> datetime.datetime: """Calculate the next specified solar event.""" - import astral + from astral import AstralError location = get_astral_location(hass) @@ -51,19 +59,22 @@ def get_astral_event_next(hass, event, utc_point_in_time=None, offset=None): next_dt = getattr(location, event)( dt_util.as_local(utc_point_in_time).date() + datetime.timedelta(days=mod), - local=False) + offset + local=False) + offset # type: datetime.datetime if next_dt > utc_point_in_time: return next_dt - except astral.AstralError: + except AstralError: pass mod += 1 @callback @bind_hass -def get_astral_event_date(hass, event, date=None): +def get_astral_event_date( + hass: HomeAssistantType, event: str, + date: Union[datetime.date, datetime.datetime, None] = None) \ + -> Optional[datetime.datetime]: """Calculate the astral event time for the specified date.""" - import astral + from astral import AstralError location = get_astral_location(hass) @@ -74,15 +85,16 @@ def get_astral_event_date(hass, event, date=None): date = dt_util.as_local(date).date() try: - return getattr(location, event)(date, local=False) - except astral.AstralError: + return getattr(location, event)(date, local=False) # type: ignore + except AstralError: # Event never occurs for specified date. return None @callback @bind_hass -def is_up(hass, utc_point_in_time=None): +def is_up(hass: HomeAssistantType, + utc_point_in_time: Optional[datetime.datetime] = None) -> bool: """Calculate if the sun is currently up.""" if utc_point_in_time is None: utc_point_in_time = dt_util.utcnow() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6fb003926e1..61aacd3b233 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -209,8 +209,9 @@ def load_order_component(hass, # type: HomeAssistant comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. - Raises HomeAssistantError if a circular dependency is detected. - Returns an empty list if component could not be loaded. + Returns an empty list if a circular dependency is detected + or the component could not be loaded. In both cases, the error is + logged. Async friendly. """ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 578cd915fcd..11f96591705 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -10,8 +10,8 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 -ruamel.yaml==0.15.72 +requests==2.20.1 +ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 @@ -22,3 +22,6 @@ enum34==1000000000.0.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 + +# Contains code to modify Home Assistant to work around our rules +python-systemair-savecair==1000000000.0.0 diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py index 84ba20619d8..302910c5b08 100644 --- a/homeassistant/scripts/credstash.py +++ b/homeassistant/scripts/credstash.py @@ -4,7 +4,7 @@ import getpass from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['credstash==1.14.0', 'botocore==1.7.34'] +REQUIREMENTS = ['credstash==1.15.0', 'botocore==1.7.34'] def run(args): diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py deleted file mode 100644 index 419f1138bf0..00000000000 --- a/homeassistant/scripts/db_migrator.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Script to convert an old-format home-assistant.db to a new format one.""" - -import argparse -import os.path -import sqlite3 -import sys - -from datetime import datetime -from typing import Optional, List - -import homeassistant.config as config_util -import homeassistant.util.dt as dt_util -# pylint: disable=unused-import -from homeassistant.components.recorder import REQUIREMENTS # NOQA - - -def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]: - """Turn a datetime into an integer for in the DB.""" - if timestamp is None: - return None - return dt_util.utc_from_timestamp(timestamp) - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print("\n") - - -def run(script_args: List) -> int: - """Run the actual script.""" - # pylint: disable=invalid-name - from sqlalchemy import create_engine - from sqlalchemy.orm import sessionmaker - from homeassistant.components.recorder import models - - parser = argparse.ArgumentParser( - description="Migrate legacy DB to SQLAlchemy format.") - parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") - parser.add_argument( - '-a', '--append', - action='store_true', - default=False, - help="Append to existing new format SQLite database") - parser.add_argument( - '--uri', - type=str, - help="Connect to URI and import (implies --append)" - "eg: mysql://localhost/homeassistant") - parser.add_argument( - '--script', - choices=['db_migrator']) - - args = parser.parse_args() - - config_dir = os.path.join(os.getcwd(), args.config) # type: str - - # Test if configuration directory exists - if not os.path.isdir(config_dir): - if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) - return 1 - - src_db = '{}/home-assistant.db'.format(config_dir) - dst_db = '{}/home-assistant_v2.db'.format(config_dir) - - if not os.path.exists(src_db): - print("Fatal Error: Old format database '{}' does not exist".format( - src_db)) - return 1 - if not args.uri and (os.path.exists(dst_db) and not args.append): - print("Fatal Error: New format database '{}' exists already - " - "Remove it or use --append".format(dst_db)) - print("Note: --append must maintain an ID mapping and is much slower" - "and requires sufficient memory to track all event IDs") - return 1 - - conn = sqlite3.connect(src_db) - uri = args.uri or "sqlite:///{}".format(dst_db) - - engine = create_engine(uri, echo=False) - models.Base.metadata.create_all(engine) - session_factory = sessionmaker(bind=engine) - session = session_factory() - - append = args.append or args.uri - - c = conn.cursor() - c.execute("SELECT count(*) FROM recorder_runs") - num_rows = c.fetchone()[0] - print("Converting {} recorder_runs".format(num_rows)) - c.close() - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM recorder_runs"): # type: ignore - n += 1 - session.add(models.RecorderRuns( - start=ts_to_dt(row[1]), - end=ts_to_dt(row[2]), - closed_incorrect=row[3], - created=ts_to_dt(row[4]) - )) - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - - c = conn.cursor() - c.execute("SELECT count(*) FROM events") - num_rows = c.fetchone()[0] - print("Converting {} events".format(num_rows)) - c.close() - - id_mapping = {} - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM events"): # type: ignore - n += 1 - o = models.Events( - event_type=row[1], - event_data=row[2], - origin=row[3], - created=ts_to_dt(row[4]), - time_fired=ts_to_dt(row[5]), - ) - session.add(o) - if append: - session.flush() - id_mapping[row[0]] = o.event_id - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - - c = conn.cursor() - c.execute("SELECT count(*) FROM states") - num_rows = c.fetchone()[0] - print("Converting {} states".format(num_rows)) - c.close() - - c = conn.cursor() - n = 0 - for row in c.execute("SELECT * FROM states"): # type: ignore - n += 1 - session.add(models.States( - entity_id=row[1], - state=row[2], - attributes=row[3], - last_changed=ts_to_dt(row[4]), - last_updated=ts_to_dt(row[5]), - event_id=id_mapping.get(row[6], row[6]), - domain=row[7] - )) - if n % 1000 == 0: - session.commit() - print_progress(n, num_rows) - print_progress(n, num_rows) - session.commit() - c.close() - return 0 diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py deleted file mode 100644 index a6dd90920c3..00000000000 --- a/homeassistant/scripts/influxdb_import.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Script to import recorded data into an Influx database.""" -import argparse -import json -import os -import sys -from typing import List - -import homeassistant.config as config_util - - -def run(script_args: List) -> int: - """Run the actual script.""" - from sqlalchemy import create_engine - from sqlalchemy import func - from sqlalchemy.orm import sessionmaker - from influxdb import InfluxDBClient - from homeassistant.components.recorder import models - from homeassistant.helpers import state as state_helper - from homeassistant.core import State - from homeassistant.core import HomeAssistantError - - parser = argparse.ArgumentParser( - description="import data to influxDB.") - parser.add_argument( - '-c', '--config', - metavar='path_to_config_dir', - default=config_util.get_default_config_dir(), - help="Directory that contains the Home Assistant configuration") - parser.add_argument( - '--uri', - type=str, - help="Connect to URI and import (if other than default sqlite) " - "eg: mysql://localhost/homeassistant") - parser.add_argument( - '-d', '--dbname', - metavar='dbname', - required=True, - help="InfluxDB database name") - parser.add_argument( - '-H', '--host', - metavar='host', - default='127.0.0.1', - help="InfluxDB host address") - parser.add_argument( - '-P', '--port', - metavar='port', - default=8086, - help="InfluxDB host port") - parser.add_argument( - '-u', '--username', - metavar='username', - default='root', - help="InfluxDB username") - parser.add_argument( - '-p', '--password', - metavar='password', - default='root', - help="InfluxDB password") - parser.add_argument( - '-s', '--step', - metavar='step', - default=1000, - help="How many points to import at the same time") - parser.add_argument( - '-t', '--tags', - metavar='tags', - default="", - help="Comma separated list of tags (key:value) for all points") - parser.add_argument( - '-D', '--default-measurement', - metavar='default_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-o', '--override-measurement', - metavar='override_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-e', '--exclude_entities', - metavar='exclude_entities', - default="", - help="Comma separated list of excluded entities") - parser.add_argument( - '-E', '--exclude_domains', - metavar='exclude_domains', - default="", - help="Comma separated list of excluded domains") - parser.add_argument( - "-S", "--simulate", - default=False, - action="store_true", - help=("Do not write points but simulate preprocessing and print " - "statistics")) - parser.add_argument( - '--script', - choices=['influxdb_import']) - - args = parser.parse_args() - simulate = args.simulate - - client = None - if not simulate: - client = InfluxDBClient( - args.host, args.port, args.username, args.password) - client.switch_database(args.dbname) - - config_dir = os.path.join(os.getcwd(), args.config) # type: str - - # Test if configuration directory exists - if not os.path.isdir(config_dir): - if config_dir != config_util.get_default_config_dir(): - print(('Fatal Error: Specified configuration directory does ' - 'not exist {} ').format(config_dir)) - return 1 - - src_db = '{}/home-assistant_v2.db'.format(config_dir) - - if not os.path.exists(src_db) and not args.uri: - print("Fatal Error: Database '{}' does not exist " - "and no URI given".format(src_db)) - return 1 - - uri = args.uri or 'sqlite:///{}'.format(src_db) - engine = create_engine(uri, echo=False) - session_factory = sessionmaker(bind=engine) - session = session_factory() - step = int(args.step) - step_start = 0 - - tags = {} - if args.tags: - tags.update(dict(elem.split(':') for elem in args.tags.split(','))) - excl_entities = args.exclude_entities.split(',') - excl_domains = args.exclude_domains.split(',') - override_measurement = args.override_measurement - default_measurement = args.default_measurement - - # pylint: disable=assignment-from-no-return - query = session.query(func.count(models.Events.event_type)).filter( - models.Events.event_type == 'state_changed') - - total_events = query.scalar() - prefix_format = '{} of {}' - - points = [] - invalid_points = [] - count = 0 - from collections import defaultdict - entities = defaultdict(int) - print_progress(0, total_events, prefix_format.format(0, total_events)) - - while True: - - step_stop = step_start + step - if step_start > total_events: - print_progress(total_events, total_events, prefix_format.format( - total_events, total_events)) - break - query = session.query(models.Events).filter( - models.Events.event_type == 'state_changed').order_by( - models.Events.time_fired).slice(step_start, step_stop) - - for event in query: - event_data = json.loads(event.event_data) - - if not ('entity_id' in event_data) or ( - excl_entities and event_data[ - 'entity_id'] in excl_entities) or ( - excl_domains and event_data[ - 'entity_id'].split('.')[0] in excl_domains): - session.expunge(event) - continue - - try: - state = State.from_dict(event_data.get('new_state')) - except HomeAssistantError: - invalid_points.append(event_data) - - if not state: - invalid_points.append(event_data) - continue - - try: - _state = float(state_helper.state_as_number(state)) - _state_key = 'value' - except ValueError: - _state = state.state - _state_key = 'state' - - if override_measurement: - measurement = override_measurement - else: - measurement = state.attributes.get('unit_of_measurement') - if measurement in (None, ''): - if default_measurement: - measurement = default_measurement - else: - measurement = state.entity_id - - point = { - 'measurement': measurement, - 'tags': { - 'domain': state.domain, - 'entity_id': state.object_id, - }, - 'time': event.time_fired, - 'fields': { - _state_key: _state, - } - } - - for key, value in state.attributes.items(): - if key != 'unit_of_measurement': - # If the key is already in fields - if key in point['fields']: - key = key + '_' - # Prevent column data errors in influxDB. - # For each value we try to cast it as float - # But if we can not do it we store the value - # as string add "_str" postfix to the field key - try: - point['fields'][key] = float(value) - except (ValueError, TypeError): - new_key = '{}_str'.format(key) - point['fields'][new_key] = str(value) - - entities[state.entity_id] += 1 - point['tags'].update(tags) - points.append(point) - session.expunge(event) - - if points: - if not simulate: - client.write_points(points) - count += len(points) - # This prevents the progress bar from going over 100% when - # the last step happens - print_progress((step_start + len( - points)), total_events, prefix_format.format( - step_start, total_events)) - else: - print_progress( - (step_start + step), total_events, prefix_format.format( - step_start, total_events)) - - points = [] - step_start += step - - print("\nStatistics:") - print("\n".join(["{:6}: {}".format(v, k) for k, v - in sorted(entities.items(), key=lambda x: x[1])])) - print("\nInvalid Points: {}".format(len(invalid_points))) - print("\nImport finished: {} points written".format(count)) - return 0 - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print('\n') diff --git a/homeassistant/scripts/influxdb_migrator.py b/homeassistant/scripts/influxdb_migrator.py deleted file mode 100644 index 04d54cd3fa8..00000000000 --- a/homeassistant/scripts/influxdb_migrator.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Script to convert an old-structure influxdb to a new one.""" - -import argparse -import sys -from typing import List - - -# Based on code at -# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console -def print_progress(iteration: int, total: int, prefix: str = '', - suffix: str = '', decimals: int = 2, - bar_length: int = 68) -> None: - """Print progress bar. - - Call in a loop to create terminal progress bar - @params: - iteration - Required : current iteration (Int) - total - Required : total iterations (Int) - prefix - Optional : prefix string (Str) - suffix - Optional : suffix string (Str) - decimals - Optional : number of decimals in percent complete (Int) - barLength - Optional : character length of bar (Int) - """ - filled_length = int(round(bar_length * iteration / float(total))) - percents = round(100.00 * (iteration / float(total)), decimals) - line = '#' * filled_length + '-' * (bar_length - filled_length) - sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line, - percents, '%', suffix)) - sys.stdout.flush() - if iteration == total: - print("\n") - - -def run(script_args: List) -> int: - """Run the actual script.""" - from influxdb import InfluxDBClient - - parser = argparse.ArgumentParser( - description="Migrate legacy influxDB.") - parser.add_argument( - '-d', '--dbname', - metavar='dbname', - required=True, - help="InfluxDB database name") - parser.add_argument( - '-H', '--host', - metavar='host', - default='127.0.0.1', - help="InfluxDB host address") - parser.add_argument( - '-P', '--port', - metavar='port', - default=8086, - help="InfluxDB host port") - parser.add_argument( - '-u', '--username', - metavar='username', - default='root', - help="InfluxDB username") - parser.add_argument( - '-p', '--password', - metavar='password', - default='root', - help="InfluxDB password") - parser.add_argument( - '-s', '--step', - metavar='step', - default=1000, - help="How many points to migrate at the same time") - parser.add_argument( - '-o', '--override-measurement', - metavar='override_measurement', - default="", - help="Store all your points in the same measurement") - parser.add_argument( - '-D', '--delete', - action='store_true', - default=False, - help="Delete old database") - parser.add_argument( - '--script', - choices=['influxdb_migrator']) - - args = parser.parse_args() - - # Get client for old DB - client = InfluxDBClient(args.host, args.port, - args.username, args.password) - client.switch_database(args.dbname) - # Get DB list - db_list = [db['name'] for db in client.get_list_database()] - # Get measurements of the old DB - res = client.query('SHOW MEASUREMENTS') - measurements = [measurement['name'] for measurement in res.get_points()] - nb_measurements = len(measurements) - # Move data - # Get old DB name - old_dbname = "{}__old".format(args.dbname) - # Create old DB if needed - if old_dbname not in db_list: - client.create_database(old_dbname) - # Copy data to the old DB - print("Cloning from {} to {}".format(args.dbname, old_dbname)) - for index, measurement in enumerate(measurements): - client.query('''SELECT * INTO {}..:MEASUREMENT FROM ''' - '"{}" GROUP BY *'.format(old_dbname, measurement)) - # Print progress - print_progress(index + 1, nb_measurements) - - # Delete the database - client.drop_database(args.dbname) - # Create new DB if needed - client.create_database(args.dbname) - client.switch_database(old_dbname) - # Get client for new DB - new_client = InfluxDBClient(args.host, args.port, args.username, - args.password, args.dbname) - # Counter of points without time - point_wt_time = 0 - - print("Migrating from {} to {}".format(old_dbname, args.dbname)) - # Walk into measurement - for index, measurement in enumerate(measurements): - - # Get tag list - res = client.query('''SHOW TAG KEYS FROM "{}"'''.format(measurement)) - tags = [v['tagKey'] for v in res.get_points()] - # Get field list - res = client.query('''SHOW FIELD KEYS FROM "{}"'''.format(measurement)) - fields = [v['fieldKey'] for v in res.get_points()] - # Get points, convert and send points to the new DB - offset = 0 - while True: - nb_points = 0 - # Prepare new points - new_points = [] - # Get points - res = client.query('SELECT * FROM "{}" LIMIT {} OFFSET ' - '{}'.format(measurement, args.step, offset)) - for point in res.get_points(): - new_point = {"tags": {}, - "fields": {}, - "time": None} - if args.override_measurement: - new_point["measurement"] = args.override_measurement - else: - new_point["measurement"] = measurement - # Check time - if point["time"] is None: - # Point without time - point_wt_time += 1 - print("Can not convert point without time") - continue - # Convert all fields - for field in fields: - try: - new_point["fields"][field] = float(point[field]) - except (ValueError, TypeError): - if field == "value": - new_key = "state" - else: - new_key = "{}_str".format(field) - new_point["fields"][new_key] = str(point[field]) - # Add tags - for tag in tags: - new_point["tags"][tag] = point[tag] - # Set time - new_point["time"] = point["time"] - # Add new point to the new list - new_points.append(new_point) - # Count nb points - nb_points += 1 - - # Send to the new db - try: - new_client.write_points(new_points) - except Exception as exp: - raise exp - - # If there is no points - if nb_points == 0: - # print("Measurement {} migrated".format(measurement)) - break - else: - # Increment offset - offset += args.step - # Print progress - print_progress(index + 1, nb_measurements) - - # Delete database if needed - if args.delete: - print("Dropping {}".format(old_dbname)) - client.drop_database(old_dbname) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 41201264da2..cc7c4284f9c 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ import logging.handlers from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict, List +from typing import Awaitable, Callable, Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -160,8 +160,8 @@ async def _async_setup_component(hass: core.HomeAssistant, log_error("Component failed to initialize.") return False if result is not True: - log_error("Component did not return boolean if setup was successful. " - "Disabling component.") + log_error("Component {!r} did not return boolean if setup was " + "successful. Disabling component.".format(domain)) loader.set_component(hass, domain, None) return False @@ -248,3 +248,35 @@ async def async_process_deps_reqs( raise HomeAssistantError("Could not install all requirements.") processed.add(name) + + +@core.callback +def async_when_setup( + hass: core.HomeAssistant, component: str, + when_setup_cb: Callable[ + [core.HomeAssistant, str], Awaitable[None]]) -> None: + """Call a method when a component is setup.""" + async def when_setup() -> None: + """Call the callback.""" + try: + await when_setup_cb(hass, component) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error handling when_setup callback for %s', + component) + + # Running it in a new task so that it always runs after + if component in hass.config.components: + hass.async_create_task(when_setup()) + return + + unsub = None + + async def loaded_event(event: core.Event) -> None: + """Call the callback.""" + if event.data[ATTR_COMPONENT] != component: + return + + unsub() # type: ignore + await when_setup() + + unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event) diff --git a/requirements_all.txt b/requirements_all.txt index 539f89cbdd1..197f9be02d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -11,8 +11,8 @@ cryptography==2.3.1 pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 -requests==2.20.0 -ruamel.yaml==0.15.72 +requests==2.20.1 +ruamel.yaml==0.15.78 voluptuous==0.11.5 voluptuous-serialize==2.0.0 @@ -32,7 +32,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.0.0 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.4.1 # homeassistant.components.notify.mastodon Mastodon.py==1.3.1 @@ -56,7 +56,7 @@ PyRMVtransport==0.1.3 PySwitchbot==0.3 # homeassistant.components.sensor.transport_nsw -PyTransportNSW==0.0.8 +PyTransportNSW==0.1.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.11.1 @@ -85,8 +85,8 @@ abodepy==0.14.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 -# homeassistant.components.device_tracker.asuswrt -aioasuswrt==1.1.6 +# homeassistant.components.asuswrt +aioasuswrt==1.1.11 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 @@ -111,7 +111,7 @@ aiohue==1.5.0 aioimaplib==0.7.13 # homeassistant.components.lifx -aiolifx==0.6.5 +aiolifx==0.6.6 # homeassistant.components.light.lifx aiolifx_effects==0.2.1 @@ -140,9 +140,6 @@ anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.media_player.anthemav anthemav==1.1.8 -# homeassistant.components.light.avion -# antsar-avion==0.9.1 - # homeassistant.components.apcupsd apcaccess==0.0.13 @@ -157,7 +154,10 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.13.0 +async-upnp-client==0.13.2 + +# homeassistant.components.light.avion +# avion==0.10 # homeassistant.components.axis axis==16 @@ -186,7 +186,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.1 +blinkpy==0.10.3 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -267,7 +267,7 @@ concord232==0.15 construct==2.9.45 # homeassistant.scripts.credstash -# credstash==1.14.0 +# credstash==1.15.0 # homeassistant.components.sensor.crimereports crimereports==1.0.0 @@ -333,7 +333,7 @@ einder==0.3.1 eliqonline==1.0.14 # homeassistant.components.elkm1 -elkm1-lib==0.7.10 +elkm1-lib==0.7.12 # homeassistant.components.enocean enocean==0.40 @@ -373,9 +373,15 @@ fedexdeliverymanager==1.0.6 # homeassistant.components.feedreader feedparser==5.2.1 +# homeassistant.components.fibaro +fiblary3==0.1.7 + # homeassistant.components.sensor.fints fints==1.0.1 +# homeassistant.components.media_player.firetv +firetv==1.0.7 + # homeassistant.components.sensor.fitbit fitbit==0.3.0 @@ -415,11 +421,14 @@ geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events georss_client==0.4 +# homeassistant.components.device_tracker.googlehome +ghlocalapi==0.1.0 + # homeassistant.components.sensor.gitter gitterpy==0.1.7 # homeassistant.components.sensor.glances -glances_api==0.1.0 +glances_api==0.2.0 # homeassistant.components.notify.gntp gntp==1.0.3 @@ -458,7 +467,7 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.5 +hdate==0.7.5 # homeassistant.components.climate.heatmiser heatmiserV3==0.9.1 @@ -476,7 +485,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.3 +home-assistant-frontend==20181121.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 @@ -516,7 +525,7 @@ ihcsdk==2.2.0 influxdb==5.0.0 # homeassistant.components.insteon -insteonplm==0.15.0 +insteonplm==0.15.1 # homeassistant.components.sensor.iperf3 iperf3==0.1.10 @@ -549,8 +558,7 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.10 -# homeassistant.components.device_tracker.owntracks -# homeassistant.components.device_tracker.owntracks_http +# homeassistant.components.owntracks libnacl==1.6.1 # homeassistant.components.dyson @@ -585,13 +593,16 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.7 +locationsharinglib==3.0.8 # homeassistant.components.logi_circle logi_circle==0.1.7 -# homeassistant.components.sensor.luftdaten -luftdaten==0.2.0 +# homeassistant.components.luftdaten +luftdaten==0.3.4 + +# homeassistant.components.lupusec +lupupy==0.0.10 # homeassistant.components.light.lw12wifi lw12==0.9.2 @@ -600,7 +611,7 @@ lw12==0.9.2 lyft_rides==0.2 # homeassistant.components.sensor.magicseaweed -magicseaweed==1.0.0 +magicseaweed==1.0.3 # homeassistant.components.matrix matrix-client==0.2.0 @@ -622,7 +633,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.2 +millheater==0.2.8 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 @@ -661,6 +672,9 @@ netdisco==2.2.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 +# homeassistant.components.light.niko_home_control +niko-home-control==0.1.8 + # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 @@ -674,7 +688,7 @@ nuheat==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.3 +numpy==1.15.4 # homeassistant.components.google oauth2client==4.0.0 @@ -701,6 +715,9 @@ orvibo==1.1.1 # homeassistant.components.shiftr paho-mqtt==1.4.0 +# homeassistant.components.media_player.panasonic_bluray +panacotta==0.1 + # homeassistant.components.media_player.panasonic_viera panasonic_viera==0.3.1 @@ -772,7 +789,7 @@ pushetta==1.0.15 pwmled==1.3.0 # homeassistant.components.august -py-august==0.6.0 +py-august==0.7.0 # homeassistant.components.canary py-canary==0.5.0 @@ -786,6 +803,9 @@ py-melissa-climate==2.0.0 # homeassistant.components.camera.synology py-synology==0.2.0 +# homeassistant.components.sensor.seventeentrack +py17track==2.0.2 + # homeassistant.components.hdmi_cec pyCEC==0.4.13 @@ -800,14 +820,17 @@ pyMetno==0.3.0 pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.4.2 +pySwitchmate==0.4.3 # homeassistant.components.tibber -pyTibber==0.7.2 +pyTibber==0.8.2 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.w800rf32 +pyW800rf32==0.1 + # homeassistant.components.sensor.noaa_tides # py_noaa==0.3.0 @@ -865,7 +888,7 @@ pycsspeechtts==1.0.2 # homeassistant.components.daikin # homeassistant.components.climate.daikin -pydaikin==0.6 +pydaikin==0.8 # homeassistant.components.deconz pydeconz==47 @@ -909,6 +932,9 @@ pyflexit==0.3 # homeassistant.components.binary_sensor.flic pyflic-homeassistant==0.4.dev0 +# homeassistant.components.sensor.flunearyou +pyflunearyou==0.0.2 + # homeassistant.components.light.futurenow pyfnip==0.2 @@ -932,7 +958,7 @@ pygtfs-homeassistant==0.1.3.dev0 pyharmony==1.0.20 # homeassistant.components.sensor.version -pyhaversion==2.0.2 +pyhaversion==2.0.3 # homeassistant.components.binary_sensor.hikvision pyhik==0.1.8 @@ -941,13 +967,13 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.51 +pyhomematic==0.1.52 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 # homeassistant.components.alarm_control_panel.ialarm -pyialarm==0.2 +pyialarm==0.3 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 @@ -976,6 +1002,9 @@ pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm pylast==2.4.0 +# homeassistant.components.sensor.launch_library +pylaunches==0.1.2 + # homeassistant.components.media_player.lg_netcast pylgnetcast-homeassistant==0.2.0.dev0 @@ -1020,13 +1049,13 @@ pymonoprice==0.3 pymusiccast==0.1.6 # homeassistant.components.cover.myq -pymyq==0.0.15 +pymyq==1.0.0 # homeassistant.components.mysensors -pymysensors==0.17.0 +pymysensors==0.18.0 # homeassistant.components.lock.nello -pynello==1.5.1 +pynello==2.0.2 # homeassistant.components.device_tracker.netgear pynetgear==0.5.1 @@ -1068,6 +1097,9 @@ pyowm==2.9.0 # homeassistant.components.media_player.pjlink pypjlink2==1.2.0 +# homeassistant.components.point +pypoint==1.0.6 + # homeassistant.components.sensor.pollen pypollencom==2.2.2 @@ -1080,6 +1112,9 @@ pyrainbird==0.1.6 # homeassistant.components.switch.recswitch pyrecswitch==1.0.2 +# homeassistant.components.sensor.ruter +pyruter==1.1.0 + # homeassistant.components.sabnzbd pysabnzbd==1.1.0 @@ -1107,7 +1142,7 @@ pysma==0.2.2 pysnmp==4.4.5 # homeassistant.components.sonos -pysonos==0.0.3 +pysonos==0.0.5 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -1118,6 +1153,9 @@ pystride==0.1.7 # homeassistant.components.sensor.syncthru pysyncthru==0.3.1 +# homeassistant.components.sensor.tautulli +pytautulli==0.4.0 + # homeassistant.components.media_player.liveboxplaytv pyteleloisirs==3.4 @@ -1186,7 +1224,7 @@ python-mpd2==1.0.0 python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==4.0.3 +python-nest==4.0.5 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 @@ -1237,11 +1275,14 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==2.0.2 +pytile==2.0.5 # homeassistant.components.climate.touchline pytouchline==0.7 +# homeassistant.components.device_tracker.traccar +pytraccar==0.1.2 + # homeassistant.components.device_tracker.trackr pytrackr==0.0.5 @@ -1276,7 +1317,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.28 +pywemo==0.4.29 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 @@ -1303,7 +1344,7 @@ raincloudy==0.0.5 # raspihats==2.2.3 # homeassistant.components.rainmachine -regenmaschine==1.0.2 +regenmaschine==1.0.7 # homeassistant.components.python_script restrictedpython==4.0b6 @@ -1382,7 +1423,7 @@ slacker==0.9.65 sleepyq==0.6 # homeassistant.components.notify.xmpp -slixmpp==1.4.0 +slixmpp==1.4.1 # homeassistant.components.smappee smappy==0.2.16 @@ -1420,9 +1461,11 @@ spotcrime==1.0.3 spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.recorder -# homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.13 +sqlalchemy==1.2.14 + +# homeassistant.components.sensor.srp_energy +srpenergy==1.0.5 # homeassistant.components.sensor.starlingbank starlingbank==1.2 @@ -1445,6 +1488,9 @@ suds-passworddigest-homeassistant==0.1.2a0.dev0 # homeassistant.components.camera.onvif suds-py3==1.3.3.0 +# homeassistant.components.sensor.swiss_hydrological_data +swisshydrodata==0.0.3 + # homeassistant.components.tahoma tahoma-api==0.0.13 @@ -1485,10 +1531,13 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonlib==1.0.2 +toonlib==1.1.3 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.20 +total_connect_client==0.22 + +# homeassistant.components.tplink_lte +tp-connected==0.0.4 # homeassistant.components.device_tracker.tplink tplink==0.2.1 @@ -1528,7 +1577,7 @@ volkszaehler==0.1.2 volvooncall==0.4.0 # homeassistant.components.verisure -vsure==1.5.0 +vsure==1.5.2 # homeassistant.components.sensor.vasttrafik vtjp==0.1.14 @@ -1574,7 +1623,6 @@ xknx==0.9.1 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca -# homeassistant.components.sensor.swiss_hydrological_data # homeassistant.components.sensor.ted5000 # homeassistant.components.sensor.yr # homeassistant.components.sensor.zestimate @@ -1594,7 +1642,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.10.29 +youtube_dl==2018.11.07 # homeassistant.components.light.zengge zengge==0.2 @@ -1606,7 +1654,7 @@ zeroconf==0.21.3 zhong_hong_hvac==1.0.9 # homeassistant.components.media_player.ziggo_mediabox_xl -ziggo-mediabox-xl==1.0.0 +ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha zigpy-xbee==0.1.1 diff --git a/requirements_docs.txt b/requirements_docs.txt index cd2eb1a0be6..16c861a75fc 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ Sphinx==1.8.1 -sphinx-autodoc-typehints==1.3.0 +sphinx-autodoc-typehints==1.5.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index 68248a47cdb..204bc67b086 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,8 +10,8 @@ mypy==0.641 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 -pytest-cov==2.5.1 -pytest-sugar==0.9.1 +pytest-cov==2.6.0 +pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.9.3 +pytest==4.0.0 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b0e151feba..f7223771891 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -11,21 +11,21 @@ mypy==0.641 pydocstyle==2.1.1 pylint==2.1.1 pytest-aiohttp==0.3.0 -pytest-cov==2.5.1 -pytest-sugar==0.9.1 +pytest-cov==2.6.0 +pytest-sugar==0.9.2 pytest-timeout==1.3.2 -pytest==3.9.3 +pytest==4.0.0 requests_mock==1.5.2 # homeassistant.components.homekit -HAP-python==2.2.2 +HAP-python==2.4.1 # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.1.3 # homeassistant.components.sensor.transport_nsw -PyTransportNSW==0.0.8 +PyTransportNSW==0.1.1 # homeassistant.components.notify.yessssms YesssSMS==0.2.3 @@ -91,13 +91,13 @@ hangups==0.4.6 hbmqtt==0.9.4 # homeassistant.components.sensor.jewish_calendar -hdate==0.6.5 +hdate==0.7.5 # homeassistant.components.binary_sensor.workday holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181103.3 +home-assistant-frontend==20181121.1 # homeassistant.components.homematicip_cloud homematicip==0.9.8 @@ -112,6 +112,9 @@ libpurecoollink==0.4.2 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 +# homeassistant.components.luftdaten +luftdaten==0.3.4 + # homeassistant.components.sensor.mfi # homeassistant.components.switch.mfi mficlient==0.3.0 @@ -120,7 +123,7 @@ mficlient==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.15.3 +numpy==1.15.4 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -159,7 +162,7 @@ pydeconz==47 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.51 +pyhomematic==0.1.52 # homeassistant.components.litejet pylitejet==0.1 @@ -183,7 +186,7 @@ pyotp==2.2.6 pyqwikswitch==0.8 # homeassistant.components.sonos -pysonos==0.0.3 +pysonos==0.0.5 # homeassistant.components.spc pyspcwebgw==0.4.0 @@ -193,7 +196,7 @@ pyspcwebgw==0.4.0 python-forecastio==1.4.0 # homeassistant.components.nest -python-nest==4.0.3 +python-nest==4.0.5 # homeassistant.components.sensor.whois pythonwhois==2.4.3 @@ -207,6 +210,9 @@ pyunifi==2.13 # homeassistant.components.notify.html5 pywebpush==1.6.0 +# homeassistant.components.rainmachine +regenmaschine==1.0.7 + # homeassistant.components.python_script restrictedpython==4.0b6 @@ -232,9 +238,11 @@ smhi-pkg==1.0.5 somecomfort==0.5.2 # homeassistant.components.recorder -# homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.13 +sqlalchemy==1.2.14 + +# homeassistant.components.sensor.srp_energy +srpenergy==1.0.5 # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 97711b5e893..76a9e05de33 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -50,12 +50,12 @@ TEST_REQUIREMENTS = ( 'evohomeclient', 'feedparser', 'foobot_async', - 'gTTS-token', 'geojson_client', 'georss_client', + 'gTTS-token', + 'ha-ffmpeg', 'hangups', 'HAP-python', - 'ha-ffmpeg', 'haversine', 'hbmqtt', 'hdate', @@ -65,6 +65,7 @@ TEST_REQUIREMENTS = ( 'influxdb', 'libpurecoollink', 'libsoundtouch', + 'luftdaten', 'mficlient', 'numpy', 'paho-mqtt', @@ -94,6 +95,7 @@ TEST_REQUIREMENTS = ( 'pyunifi', 'pyupnp-async', 'pywebpush', + 'regenmaschine', 'restrictedpython', 'rflink', 'ring_doorbell', @@ -103,6 +105,7 @@ TEST_REQUIREMENTS = ( 'smhi-pkg', 'somecomfort', 'sqlalchemy', + 'srpenergy', 'statsd', 'uvcclient', 'warrant', @@ -139,6 +142,9 @@ enum34==1000000000.0.0 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 + +# Contains code to modify Home Assistant to work around our rules +python-systemair-savecair==1000000000.0.0 """ diff --git a/setup.py b/setup.py index 7cd551b573a..49147afdd70 100755 --- a/setup.py +++ b/setup.py @@ -45,8 +45,8 @@ REQUIRES = [ 'pip>=8.0.3', 'pytz>=2018.04', 'pyyaml>=3.13,<4', - 'requests==2.20.0', - 'ruamel.yaml==0.15.72', + 'requests==2.20.1', + 'ruamel.yaml==0.15.78', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', ] diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 33c164d12b4..40de5ca7334 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -10,7 +10,7 @@ def test_entities_none(): """Test entity ID policy.""" policy = None compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_empty(): @@ -18,7 +18,7 @@ def test_entities_empty(): policy = {} ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is False def test_entities_false(): @@ -33,7 +33,7 @@ def test_entities_true(): policy = True ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_true(): @@ -43,7 +43,7 @@ def test_entities_domains_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_domains_domain_true(): @@ -55,8 +55,8 @@ def test_entities_domains_domain_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_domains_domain_false(): @@ -77,7 +77,7 @@ def test_entities_entity_ids_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True def test_entities_entity_ids_false(): @@ -98,8 +98,8 @@ def test_entities_entity_ids_entity_id_true(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('switch.kitchen', ('read',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('switch.kitchen', 'read') is False def test_entities_entity_ids_entity_id_false(): @@ -124,9 +124,9 @@ def test_entities_control_only(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('light.kitchen', 'edit') is False def test_entities_read_control(): @@ -141,9 +141,9 @@ def test_entities_read_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('light.kitchen', ('edit',)) is False + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False def test_entities_all_allow(): @@ -153,9 +153,9 @@ def test_entities_all_allow(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is True def test_entities_all_read(): @@ -167,9 +167,9 @@ def test_entities_all_read(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is True - assert compiled('light.kitchen', ('control',)) is False - assert compiled('switch.kitchen', ('read',)) is True + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is False + assert compiled('switch.kitchen', 'read') is True def test_entities_all_control(): @@ -181,7 +181,7 @@ def test_entities_all_control(): } ENTITY_POLICY_SCHEMA(policy) compiled = compile_entities(policy) - assert compiled('light.kitchen', ('read',)) is False - assert compiled('light.kitchen', ('control',)) is True - assert compiled('switch.kitchen', ('read',)) is False - assert compiled('switch.kitchen', ('control',)) is True + assert compiled('light.kitchen', 'read') is False + assert compiled('light.kitchen', 'control') is True + assert compiled('switch.kitchen', 'read') is False + assert compiled('switch.kitchen', 'control') is True diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py deleted file mode 100644 index 60ec3cb4314..00000000000 --- a/tests/auth/permissions/test_init.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Tests for the auth permission system.""" -from homeassistant.core import State -from homeassistant.auth import permissions - - -def test_policy_perm_filter_states(): - """Test filtering entitites.""" - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - perm = permissions.PolicyPermissions({ - 'entities': { - 'entity_ids': { - 'light.kitchen': True, - 'light.balcony': True, - } - } - }) - filtered = perm.filter_states(states) - assert len(filtered) == 2 - assert filtered == [states[0], states[2]] - - -def test_owner_permissions(): - """Test owner permissions access all.""" - assert permissions.OwnerPermissions.check_entity('light.kitchen', 'write') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert permissions.OwnerPermissions.filter_states(states) == states - - -def test_default_policy_allow_all(): - """Test that the default policy is to allow all entity actions.""" - perm = permissions.PolicyPermissions(permissions.DEFAULT_POLICY) - assert perm.check_entity('light.kitchen', 'read') - states = [ - State('light.kitchen', 'on'), - State('light.living_room', 'off'), - State('light.balcony', 'on'), - ] - assert perm.filter_states(states) == states diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py new file mode 100644 index 00000000000..ba6fe214146 --- /dev/null +++ b/tests/auth/permissions/test_system_policies.py @@ -0,0 +1,25 @@ +"""Test system policies.""" +from homeassistant.auth.permissions import ( + PolicyPermissions, system_policies, POLICY_SCHEMA) + + +def test_admin_policy(): + """Test admin policy works.""" + # Make sure it's valid + POLICY_SCHEMA(system_policies.ADMIN_POLICY) + + perms = PolicyPermissions(system_policies.ADMIN_POLICY) + 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 + POLICY_SCHEMA(system_policies.READ_ONLY_POLICY) + + perms = PolicyPermissions(system_policies.READ_ONLY_POLICY) + assert perms.check_entity('light.kitchen', 'read') + assert not perms.check_entity('light.kitchen', 'control') + assert not perms.check_entity('light.kitchen', 'edit') diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index a3bdbab93d7..b76d68fbeac 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -2,8 +2,8 @@ from homeassistant.auth import auth_store -async def test_loading_old_data_format(hass, hass_storage): - """Test we correctly load an old data format.""" +async def test_loading_no_group_data_format(hass, hass_storage): + """Test we correctly load old data without any groups.""" hass_storage[auth_store.STORAGE_KEY] = { 'version': 1, 'data': { @@ -60,9 +60,15 @@ async def test_loading_old_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 1 - group = groups[0] - assert group.name == "All Access" + assert len(groups) == 2 + 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] + 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 users = await store.async_get_users() assert len(users) == 2 @@ -70,7 +76,7 @@ async def test_loading_old_data_format(hass, hass_storage): owner, system = users assert owner.system_generated is False - assert owner.groups == [group] + assert owner.groups == [admin_group] assert len(owner.refresh_tokens) == 1 owner_token = list(owner.refresh_tokens.values())[0] assert owner_token.id == 'user-token-id' @@ -80,3 +86,126 @@ async def test_loading_old_data_format(hass, hass_storage): assert len(system.refresh_tokens) == 1 system_token = list(system.refresh_tokens.values())[0] assert system_token.id == 'system-token-id' + + +async def test_loading_all_access_group_data_format(hass, hass_storage): + """Test we correctly load old data with single group.""" + hass_storage[auth_store.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'credentials': [], + 'users': [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + 'group_ids': ['abcd-all-access'] + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + } + ], + "groups": [ + { + "id": "abcd-all-access", + "name": "All Access", + } + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": None, + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "system-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "system-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "hidden-because-no-jwt-id", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + ] + } + } + + store = auth_store.AuthStore(hass) + groups = await store.async_get_groups() + assert len(groups) == 2 + 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] + 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 + + users = await store.async_get_users() + assert len(users) == 2 + + owner, system = users + + assert owner.system_generated is False + assert owner.groups == [admin_group] + assert len(owner.refresh_tokens) == 1 + owner_token = list(owner.refresh_tokens.values())[0] + assert owner_token.id == 'user-token-id' + + assert system.system_generated is True + assert system.groups == [] + assert len(system.refresh_tokens) == 1 + system_token = list(system.refresh_tokens.values())[0] + assert system_token.id == 'system-token-id' + + +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 + 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] + 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 + + users = await store.async_get_users() + assert len(users) == 0 + + +async def test_system_groups_only_store_id(hass, hass_storage): + """Test that for system groups we only store the ID.""" + store = auth_store.AuthStore(hass) + await store._async_load() + data = store._data_to_save() + assert len(data['users']) == 0 + assert data['groups'] == [ + {'id': auth_store.GROUP_ID_ADMIN}, + {'id': auth_store.GROUP_ID_READ_ONLY}, + ] diff --git a/tests/common.py b/tests/common.py index 44f934e4cb3..d5056e220f0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -14,7 +14,9 @@ from contextlib import contextmanager from homeassistant import auth, core as ha, config_entries from homeassistant.auth import ( - models as auth_models, auth_store, providers as auth_providers) + models as auth_models, auth_store, providers as auth_providers, + permissions as auth_permissions) +from homeassistant.auth.permissions import system_policies from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( @@ -294,6 +296,7 @@ def async_mock_mqtt_component(hass, config=None): with patch('paho.mqtt.client.Client') as mock_client: mock_client().connect.return_value = 0 mock_client().subscribe.return_value = (0, 0) + mock_client().unsubscribe.return_value = (0, 0) mock_client().publish.return_value = (0, 0) result = yield from async_setup_component(hass, mqtt.DOMAIN, { @@ -349,7 +352,7 @@ class MockGroup(auth_models.Group): """Mock a group in Home Assistant.""" def __init__(self, id=None, name='Mock Group', - policy=auth_store.DEFAULT_POLICY): + policy=system_policies.ADMIN_POLICY): """Mock a group.""" kwargs = { 'name': name, @@ -398,6 +401,10 @@ class MockUser(auth_models.User): auth_mgr._store._users[self.id] = self return self + def mock_policy(self, policy): + """Mock a policy for a user.""" + self._permissions = auth_permissions.PolicyPermissions(policy) + async def register_auth_provider(hass, config): """Register an auth provider.""" diff --git a/tests/components/alarm_control_panel/common.py b/tests/components/alarm_control_panel/common.py index cf2de857076..829c05fef31 100644 --- a/tests/components/alarm_control_panel/common.py +++ b/tests/components/alarm_control_panel/common.py @@ -9,6 +9,21 @@ from homeassistant.const import ( SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS) from homeassistant.loader import bind_hass +from homeassistant.core import callback + + +@callback +@bind_hass +def async_alarm_disarm(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_DISARM, data)) @bind_hass @@ -23,6 +38,20 @@ def alarm_disarm(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data) +@callback +@bind_hass +def async_alarm_arm_home(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_HOME, data)) + + @bind_hass def alarm_arm_home(hass, code=None, entity_id=None): """Send the alarm the command for arm home.""" @@ -35,6 +64,20 @@ def alarm_arm_home(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data) +@callback +@bind_hass +def async_alarm_arm_away(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)) + + @bind_hass def alarm_arm_away(hass, code=None, entity_id=None): """Send the alarm the command for arm away.""" @@ -47,6 +90,20 @@ def alarm_arm_away(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data) +@callback +@bind_hass +def async_alarm_arm_night(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data)) + + @bind_hass def alarm_arm_night(hass, code=None, entity_id=None): """Send the alarm the command for arm night.""" @@ -59,6 +116,20 @@ def alarm_arm_night(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data) +@callback +@bind_hass +def async_alarm_trigger(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_ALARM_TRIGGER, data)) + + @bind_hass def alarm_trigger(hass, code=None, entity_id=None): """Send the alarm the command for trigger.""" @@ -71,6 +142,21 @@ def alarm_trigger(hass, code=None, entity_id=None): hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data) +@callback +@bind_hass +def async_alarm_arm_custom_bypass(hass, code=None, entity_id=None): + """Send the alarm the command for disarm.""" + data = {} + if code: + data[ATTR_CODE] = code + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.async_create_task( + hass.services.async_call( + DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, data)) + + @bind_hass def alarm_arm_custom_bypass(hass, code=None, entity_id=None): """Send the alarm the command for arm custom bypass.""" diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py index b39d4ecbbe9..36bae21dc32 100644 --- a/tests/components/alarm_control_panel/test_manual.py +++ b/tests/components/alarm_control_panel/test_manual.py @@ -1,16 +1,15 @@ """The tests for the manual Alarm Control Panel component.""" from datetime import timedelta -import unittest from unittest.mock import patch, MagicMock from homeassistant.components.alarm_control_panel import demo -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.components import alarm_control_panel import homeassistant.util.dt as dt_util -from tests.common import (fire_time_changed, get_test_home_assistant, +from tests.common import (async_fire_time_changed, mock_component, mock_restore_cache) from tests.components.alarm_control_panel import common from homeassistant.core import State, CoreState @@ -18,1306 +17,1326 @@ from homeassistant.core import State, CoreState CODE = 'HELLO_CODE' -class TestAlarmControlPanelManual(unittest.TestCase): - """Test the manual alarm module.""" +async def test_setup_demo_platform(hass): + """Test setup.""" + mock = MagicMock() + add_entities = mock.MagicMock() + await demo.async_setup_platform(hass, {}, add_entities) + assert add_entities.call_count == 1 - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - def tearDown(self): # pylint: disable=invalid-name - """Stop down everything that was started.""" - self.hass.stop() +async def test_arm_home_no_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) - def test_setup_demo_platform(self): - """Test setup.""" - mock = MagicMock() - add_entities = mock.MagicMock() - demo.setup_platform(self.hass, {}, add_entities) - assert add_entities.call_count == 1 + entity_id = 'alarm_control_panel.test' - def test_arm_home_no_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state - entity_id = 'alarm_control_panel.test' + common.async_alarm_arm_home(hass, CODE) + await hass.async_block_till_done() - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state - common.alarm_arm_home(self.hass, CODE) - self.hass.block_till_done() - assert STATE_ALARM_ARMED_HOME == \ - self.hass.states.get(entity_id).state +async def test_arm_home_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) - def test_arm_home_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_HOME + + +async def test_arm_home_with_invalid_code(hass): + """Attempt to arm home without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_arm_away_no_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_arm_home_with_template_code(hass): + """Attempt to arm with a template-based code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': '{{ "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, 'abc') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + +async def test_arm_away_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_AWAY + + +async def test_arm_away_with_invalid_code(hass): + """Attempt to arm away without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_arm_night_no_pending(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_arm_night_with_pending(hass): + """Test arm night method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_NIGHT + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_NIGHT + + # Do not go to the pending state when updating to the same state + common.async_alarm_arm_night(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_arm_night_with_invalid_code(hass): + """Attempt to night home without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_night(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_no_pending(hass): + """Test triggering when no pending submitted method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=60) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_TRIGGERED == state.state + + +async def test_trigger_zero_trigger_time(hass): + """Test disabled trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 0, + 'trigger_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_zero_trigger_time_with_pending(hass): + """Test disabled trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 2, + 'trigger_time': 3, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_DISARMED + + +async def test_trigger_with_unused_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 5, + 'pending_time': 0, + 'armed_home': { + 'delay_time': 10 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_TRIGGERED == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_pending_and_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 1, + 'pending_time': 0, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_trigger_with_pending_and_specific_delay(hass): + """Test trigger method and switch from pending to triggered.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'delay_time': 10, + 'pending_time': 0, + 'armed_away': { + 'delay_time': 1 + }, + 'triggered': { + 'pending_time': 1 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_PENDING + assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED + + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_TRIGGERED + + +async def test_armed_home_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_home': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_home(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_HOME == \ + hass.states.get(entity_id).state + + +async def test_armed_away_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_away': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_away(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_armed_night_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_night': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_night(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_NIGHT == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_specific_pending(hass): + """Test arm home method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'triggered': { + 'pending_time': 2 + }, + 'trigger_time': 3, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_zero_specific_trigger_time(hass): + """Test trigger method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarmed': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_unused_zero_specific_trigger_time(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'armed_home': { + 'trigger_time': 0 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_specific_trigger_time(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'disarmed': { + 'trigger_time': 5 + }, + 'pending_time': 0, + 'disarm_after_trigger': True + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_trigger_with_no_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_back_to_back_trigger_with_no_disarm_after_trigger(hass): + """Test disarm after trigger.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_AWAY == \ + hass.states.get(entity_id).state + + +async def test_disarm_while_pending_trigger(hass): + """Test disarming while pending state.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'trigger_time': 5, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + common.async_alarm_disarm(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_disarm_during_trigger_with_invalid_code(hass): + """Test disarming while code is invalid.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 5, + 'code': CODE + '2', + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_trigger(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + common.async_alarm_disarm(hass, entity_id=entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=5) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_TRIGGERED == \ + hass.states.get(entity_id).state + + +async def test_disarm_with_template_code(hass): + """Attempt to disarm with a valid or invalid template-based code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code_template': + '{{ "" if from_state == "disarmed" else "abc" }}', + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_home(hass, 'def') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + common.async_alarm_disarm(hass, 'def') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_HOME == state.state + + common.async_alarm_disarm(hass, 'abc') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_DISARMED == state.state + + +async def test_arm_custom_bypass_no_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + +async def test_arm_custom_bypass_with_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE, entity_id) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + state = hass.states.get(entity_id) + assert state.attributes['post_pending_state'] == \ + STATE_ALARM_ARMED_CUSTOM_BYPASS + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS + + +async def test_arm_custom_bypass_with_invalid_code(hass): + """Attempt to custom bypass without a valid code.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 1, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_custom_bypass(hass, CODE + '2') + await hass.async_block_till_done() + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + +async def test_armed_custom_bypass_with_specific_pending(hass): + """Test arm custom bypass method.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'pending_time': 10, + 'armed_custom_bypass': { + 'pending_time': 2 + } + }}) + + entity_id = 'alarm_control_panel.test' + + common.async_alarm_arm_custom_bypass(hass) + await hass.async_block_till_done() + + assert STATE_ALARM_PENDING == \ + hass.states.get(entity_id).state + + future = dt_util.utcnow() + timedelta(seconds=2) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ + hass.states.get(entity_id).state + + +async def test_arm_away_after_disabled_disarmed(hass): + """Test pending state with and without zero trigger time.""" + assert await async_setup_component( + hass, alarm_control_panel.DOMAIN, + {'alarm_control_panel': { + 'platform': 'manual', + 'name': 'test', + 'code': CODE, + 'pending_time': 0, + 'delay_time': 1, + 'armed_away': { 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_HOME - - def test_arm_home_with_invalid_code(self): - """Attempt to arm home without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_arm_away_no_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_arm_home_with_template_code(self): - """Attempt to arm with a template-based code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code_template': '{{ "abc" }}', - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - self.hass.start() - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, 'abc') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - def test_arm_away_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_AWAY - - def test_arm_away_with_invalid_code(self): - """Attempt to arm away without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_arm_night_no_pending(self): - """Test arm night method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_arm_night_with_pending(self): - """Test arm night method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == \ - STATE_ALARM_ARMED_NIGHT - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_NIGHT - - # Do not go to the pending state when updating to the same state - common.alarm_arm_night(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_arm_night_with_invalid_code(self): - """Attempt to night home without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_night(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_no_pending(self): - """Test triggering when no pending submitted method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=60) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 1, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) + }, + 'disarmed': { + 'trigger_time': 0 + }, + 'disarm_after_trigger': False + }}) + + entity_id = 'alarm_control_panel.test' + + assert STATE_ALARM_DISARMED == \ + hass.states.get(entity_id).state + + common.async_alarm_arm_away(hass, CODE) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_DISARMED == \ + state.attributes['pre_pending_state'] + assert STATE_ALARM_ARMED_AWAY == \ + state.attributes['post_pending_state'] + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_PENDING == state.state + assert STATE_ALARM_DISARMED == \ + state.attributes['pre_pending_state'] + assert STATE_ALARM_ARMED_AWAY == \ + state.attributes['post_pending_state'] + + future = dt_util.utcnow() + timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert STATE_ALARM_ARMED_AWAY == state.state + + common.async_alarm_trigger(hass, entity_id=entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_TRIGGERED == state.state - - def test_trigger_zero_trigger_time(self): - """Test disabled trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 0, - 'trigger_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_zero_trigger_time_with_pending(self): - """Test disabled trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 2, - 'trigger_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 2, - 'trigger_time': 3, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_DISARMED - - def test_trigger_with_unused_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 5, - 'pending_time': 0, - 'armed_home': { - 'delay_time': 10 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 10, - 'pending_time': 0, - 'armed_away': { - 'delay_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_pending_and_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 1, - 'pending_time': 0, - 'triggered': { - 'pending_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_trigger_with_pending_and_specific_delay(self): - """Test trigger method and switch from pending to triggered.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'delay_time': 10, - 'pending_time': 0, - 'armed_away': { - 'delay_time': 1 - }, - 'triggered': { - 'pending_time': 1 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_PENDING - assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_TRIGGERED - - def test_armed_home_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_home': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_home(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_HOME == \ - self.hass.states.get(entity_id).state - - def test_armed_away_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_away': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_away(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_armed_night_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_night': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_night(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_NIGHT == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_specific_pending(self): - """Test arm home method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'triggered': { - 'pending_time': 2 - }, - 'trigger_time': 3, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_zero_specific_trigger_time(self): - """Test trigger method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'disarmed': { - 'trigger_time': 0 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_unused_zero_specific_trigger_time(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'armed_home': { - 'trigger_time': 0 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_specific_trigger_time(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'disarmed': { - 'trigger_time': 5 - }, - 'pending_time': 0, - 'disarm_after_trigger': True - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_trigger_with_no_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_back_to_back_trigger_with_no_disarm_after_trigger(self): - """Test disarm after trigger.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_AWAY == \ - self.hass.states.get(entity_id).state - - def test_disarm_while_pending_trigger(self): - """Test disarming while pending state.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'trigger_time': 5, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - common.alarm_disarm(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_disarm_during_trigger_with_invalid_code(self): - """Test disarming while code is invalid.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 5, - 'code': CODE + '2', - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_trigger(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - common.alarm_disarm(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=5) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_TRIGGERED == \ - self.hass.states.get(entity_id).state - - def test_disarm_with_template_code(self): - """Attempt to disarm with a valid or invalid template-based code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code_template': - '{{ "" if from_state == "disarmed" else "abc" }}', - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - self.hass.start() - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_home(self.hass, 'def') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - common.alarm_disarm(self.hass, 'def') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_HOME == state.state - - common.alarm_disarm(self.hass, 'abc') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_DISARMED == state.state - - def test_arm_custom_bypass_no_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ - self.hass.states.get(entity_id).state - - def test_arm_custom_bypass_with_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE, entity_id) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - state = self.hass.states.get(entity_id) - assert state.attributes['post_pending_state'] == \ - STATE_ALARM_ARMED_CUSTOM_BYPASS - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS - - def test_arm_custom_bypass_with_invalid_code(self): - """Attempt to custom bypass without a valid code.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 1, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_custom_bypass(self.hass, CODE + '2') - self.hass.block_till_done() - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - def test_armed_custom_bypass_with_specific_pending(self): - """Test arm custom bypass method.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'pending_time': 10, - 'armed_custom_bypass': { - 'pending_time': 2 - } - }}) - - entity_id = 'alarm_control_panel.test' - - common.alarm_arm_custom_bypass(self.hass) - self.hass.block_till_done() - - assert STATE_ALARM_PENDING == \ - self.hass.states.get(entity_id).state - - future = dt_util.utcnow() + timedelta(seconds=2) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \ - self.hass.states.get(entity_id).state - - def test_arm_away_after_disabled_disarmed(self): - """Test pending state with and without zero trigger time.""" - assert setup_component( - self.hass, alarm_control_panel.DOMAIN, - {'alarm_control_panel': { - 'platform': 'manual', - 'name': 'test', - 'code': CODE, - 'pending_time': 0, - 'delay_time': 1, - 'armed_away': { - 'pending_time': 1, - }, - 'disarmed': { - 'trigger_time': 0 - }, - 'disarm_after_trigger': False - }}) - - entity_id = 'alarm_control_panel.test' - - assert STATE_ALARM_DISARMED == \ - self.hass.states.get(entity_id).state - - common.alarm_arm_away(self.hass, CODE) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_DISARMED == \ state.attributes['pre_pending_state'] - assert STATE_ALARM_ARMED_AWAY == \ + assert STATE_ALARM_TRIGGERED == \ state.attributes['post_pending_state'] - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() + future += timedelta(seconds=1) + with patch(('homeassistant.components.alarm_control_panel.manual.' + 'dt_util.utcnow'), return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_DISARMED == \ - state.attributes['pre_pending_state'] - assert STATE_ALARM_ARMED_AWAY == \ - state.attributes['post_pending_state'] - - future = dt_util.utcnow() + timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_ARMED_AWAY == state.state - - common.alarm_trigger(self.hass, entity_id=entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_PENDING == state.state - assert STATE_ALARM_ARMED_AWAY == \ - state.attributes['pre_pending_state'] - assert STATE_ALARM_TRIGGERED == \ - state.attributes['post_pending_state'] - - future += timedelta(seconds=1) - with patch(('homeassistant.components.alarm_control_panel.manual.' - 'dt_util.utcnow'), return_value=future): - fire_time_changed(self.hass, future) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert STATE_ALARM_TRIGGERED == state.state + state = hass.states.get(entity_id) + assert STATE_ALARM_TRIGGERED == state.state async def test_restore_armed_state(hass): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 186a35c19ec..766075f8eb5 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -987,6 +987,32 @@ async def test_include_filters(hass): assert len(msg['payload']['endpoints']) == 3 +async def test_never_exposed_entities(hass): + """Test never exposed locks do not get discovered.""" + request = get_new_request('Alexa.Discovery', 'Discover') + + # setup test devices + hass.states.async_set( + 'group.all_locks', 'on', {'friendly_name': "Blocked locks"}) + + hass.states.async_set( + 'group.allow', 'off', {'friendly_name': "Allowed group"}) + + config = smart_home.Config(should_expose=entityfilter.generate_filter( + include_domains=['group'], + include_entities=[], + exclude_domains=[], + exclude_entities=[], + )) + + msg = await smart_home.async_handle_message(hass, config, request) + await hass.async_block_till_done() + + msg = msg['event'] + + assert len(msg['payload']['endpoints']) == 1 + + async def test_api_entity_not_exists(hass): """Test api turn on process without entity.""" request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test') @@ -1302,6 +1328,32 @@ async def test_report_dimmable_light_state(hass): properties.assert_equal('Alexa.BrightnessController', 'brightness', 0) +async def test_report_colored_light_state(hass): + """Test ColorController reports color correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'hs_color': (180, 75), + 'brightness': 128, + 'supported_features': 17}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 17}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 180, + 'saturation': 0.75, + 'brightness': 128 / 255.0, + }) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorController', 'color', { + 'hue': 0, + 'saturation': 0, + 'brightness': 0, + }) + + async def reported_properties(hass, endpoint): """Use ReportState to get properties and return them. diff --git a/tests/components/automation/test_litejet.py b/tests/components/automation/test_litejet.py index 3d88174708b..278fdab8f5f 100644 --- a/tests/components/automation/test_litejet.py +++ b/tests/components/automation/test_litejet.py @@ -1,15 +1,15 @@ """The tests for the litejet component.""" import logging -import unittest from unittest import mock from datetime import timedelta +import pytest from homeassistant import setup import homeassistant.util.dt as dt_util from homeassistant.components import litejet import homeassistant.components.automation as automation -from tests.common import (fire_time_changed, get_test_home_assistant) +from tests.common import (async_fire_time_changed, async_mock_service) _LOGGER = logging.getLogger(__name__) @@ -19,238 +19,244 @@ ENTITY_OTHER_SWITCH = 'switch.mock_switch_2' ENTITY_OTHER_SWITCH_NUMBER = 2 -class TestLiteJetTrigger(unittest.TestCase): - """Test the litejet component.""" +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') - @mock.patch('pylitejet.LiteJet') - def setup_method(self, method, mock_pylitejet): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.start() - self.switch_pressed_callbacks = {} - self.switch_released_callbacks = {} - self.calls = [] +def get_switch_name(number): + """Get a mock switch name.""" + return "Mock Switch #"+str(number) - def get_switch_name(number): - return "Mock Switch #"+str(number) + +@pytest.fixture +def mock_lj(hass): + """Initialize components.""" + with mock.patch('pylitejet.LiteJet') as mock_pylitejet: + mock_lj = mock_pylitejet.return_value + + mock_lj.switch_pressed_callbacks = {} + mock_lj.switch_released_callbacks = {} def on_switch_pressed(number, callback): - self.switch_pressed_callbacks[number] = callback + mock_lj.switch_pressed_callbacks[number] = callback def on_switch_released(number, callback): - self.switch_released_callbacks[number] = callback + mock_lj.switch_released_callbacks[number] = callback - def record_call(service): - self.calls.append(service) - - self.mock_lj = mock_pylitejet.return_value - self.mock_lj.loads.return_value = range(0) - self.mock_lj.button_switches.return_value = range(1, 3) - self.mock_lj.all_switches.return_value = range(1, 6) - self.mock_lj.scenes.return_value = range(0) - self.mock_lj.get_switch_name.side_effect = get_switch_name - self.mock_lj.on_switch_pressed.side_effect = on_switch_pressed - self.mock_lj.on_switch_released.side_effect = on_switch_released + mock_lj.loads.return_value = range(0) + mock_lj.button_switches.return_value = range(1, 3) + mock_lj.all_switches.return_value = range(1, 6) + mock_lj.scenes.return_value = range(0) + mock_lj.get_switch_name.side_effect = get_switch_name + mock_lj.on_switch_pressed.side_effect = on_switch_pressed + mock_lj.on_switch_released.side_effect = on_switch_released config = { 'litejet': { 'port': '/tmp/this_will_be_mocked' } } - assert setup.setup_component(self.hass, litejet.DOMAIN, config) + assert hass.loop.run_until_complete(setup.async_setup_component( + hass, litejet.DOMAIN, config)) - self.hass.services.register('test', 'automation', record_call) + mock_lj.start_time = dt_util.utcnow() + mock_lj.last_delta = timedelta(0) + return mock_lj - self.hass.block_till_done() - self.start_time = dt_util.utcnow() - self.last_delta = timedelta(0) +async def simulate_press(hass, mock_lj, number): + """Test to simulate a press.""" + _LOGGER.info('*** simulate press of %d', number) + callback = mock_lj.switch_pressed_callbacks.get(number) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + mock_lj.last_delta): + if callback is not None: + await hass.async_add_job(callback) + await hass.async_block_till_done() - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - def simulate_press(self, number): - """Test to simulate a press.""" - _LOGGER.info('*** simulate press of %d', number) - callback = self.switch_pressed_callbacks.get(number) - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + self.last_delta): - if callback is not None: - callback() - self.hass.block_till_done() +async def simulate_release(hass, mock_lj, number): + """Test to simulate releasing.""" + _LOGGER.info('*** simulate release of %d', number) + callback = mock_lj.switch_released_callbacks.get(number) + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + mock_lj.last_delta): + if callback is not None: + await hass.async_add_job(callback) + await hass.async_block_till_done() - def simulate_release(self, number): - """Test to simulate releasing.""" - _LOGGER.info('*** simulate release of %d', number) - callback = self.switch_released_callbacks.get(number) - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + self.last_delta): - if callback is not None: - callback() - self.hass.block_till_done() - def simulate_time(self, delta): - """Test to simulate time.""" - _LOGGER.info( - '*** simulate time change by %s: %s', - delta, - self.start_time + delta) - self.last_delta = delta - with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', - return_value=self.start_time + delta): - _LOGGER.info('now=%s', dt_util.utcnow()) - fire_time_changed(self.hass, self.start_time + delta) - self.hass.block_till_done() - _LOGGER.info('done with now=%s', dt_util.utcnow()) +async def simulate_time(hass, mock_lj, delta): + """Test to simulate time.""" + _LOGGER.info( + '*** simulate time change by %s: %s', + delta, + mock_lj.start_time + delta) + mock_lj.last_delta = delta + with mock.patch('homeassistant.helpers.condition.dt_util.utcnow', + return_value=mock_lj.start_time + delta): + _LOGGER.info('now=%s', dt_util.utcnow()) + async_fire_time_changed(hass, mock_lj.start_time + delta) + await hass.async_block_till_done() + _LOGGER.info('done with now=%s', dt_util.utcnow()) - def setup_automation(self, trigger): - """Test setting up the automation.""" - assert setup.setup_component(self.hass, automation.DOMAIN, { - automation.DOMAIN: [ - { - 'alias': 'My Test', - 'trigger': trigger, - 'action': { - 'service': 'test.automation' - } + +async def setup_automation(hass, trigger): + """Test setting up the automation.""" + assert await setup.async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [ + { + 'alias': 'My Test', + 'trigger': trigger, + 'action': { + 'service': 'test.automation' } - ] - }) - self.hass.block_till_done() - - def test_simple(self): - """Test the simplest form of a LiteJet trigger.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER - }) - - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - - assert len(self.calls) == 1 - - def test_held_more_than_short(self): - """Test a too short hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '200' } - }) + ] + }) + await hass.async_block_till_done() - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.1)) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - def test_held_more_than_long(self): - """Test a hold that is long enough.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '200' - } - }) +async def test_simple(hass, calls, mock_lj): + """Test the simplest form of a LiteJet trigger.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.3)) - assert len(self.calls) == 1 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) - def test_held_less_than_short(self): - """Test a hold that is short enough.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_less_than': { - 'milliseconds': '200' - } - }) + assert len(calls) == 1 - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.1)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 - def test_held_less_than_long(self): - """Test a hold that is too long.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_less_than': { - 'milliseconds': '200' - } - }) +async def test_held_more_than_short(hass, calls, mock_lj): + """Test a too short hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '200' + } + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.3)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 - def test_held_in_range_short(self): - """Test an in-range trigger with a too short hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - self.simulate_time(timedelta(seconds=0.05)) - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 +async def test_held_more_than_long(hass, calls, mock_lj): + """Test a hold that is long enough.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '200' + } + }) - def test_held_in_range_just_right(self): - """Test an in-range trigger with a just right hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + assert len(calls) == 1 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.2)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 1 - def test_held_in_range_long(self): - """Test an in-range trigger with a too long hold.""" - self.setup_automation({ - 'platform': 'litejet', - 'number': ENTITY_OTHER_SWITCH_NUMBER, - 'held_more_than': { - 'milliseconds': '100' - }, - 'held_less_than': { - 'milliseconds': '300' - } - }) +async def test_held_less_than_short(hass, calls, mock_lj): + """Test a hold that is short enough.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_less_than': { + 'milliseconds': '200' + } + }) - self.simulate_press(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 - self.simulate_time(timedelta(seconds=0.4)) - assert len(self.calls) == 0 - self.simulate_release(ENTITY_OTHER_SWITCH_NUMBER) - assert len(self.calls) == 0 + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.1)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 + + +async def test_held_less_than_long(hass, calls, mock_lj): + """Test a hold that is too long.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_less_than': { + 'milliseconds': '200' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.3)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + + +async def test_held_in_range_short(hass, calls, mock_lj): + """Test an in-range trigger with a too short hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + await simulate_time(hass, mock_lj, timedelta(seconds=0.05)) + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + + +async def test_held_in_range_just_right(hass, calls, mock_lj): + """Test an in-range trigger with a just right hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.2)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 1 + + +async def test_held_in_range_long(hass, calls, mock_lj): + """Test an in-range trigger with a too long hold.""" + await setup_automation(hass, { + 'platform': 'litejet', + 'number': ENTITY_OTHER_SWITCH_NUMBER, + 'held_more_than': { + 'milliseconds': '100' + }, + 'held_less_than': { + 'milliseconds': '300' + } + }) + + await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 + await simulate_time(hass, mock_lj, timedelta(seconds=0.4)) + assert len(calls) == 0 + await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER) + assert len(calls) == 0 diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index d49bbb329e4..88bd39ebfe2 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -284,7 +284,8 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' - ' "status_topic": "test_topic" }' + ' "state_topic": "test_topic",' + ' "availability_topic": "availability_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', data) @@ -300,6 +301,71 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): + """Test removal of discovered binary_sensor.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "availability_topic": "availability_topic1" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic2",' + ' "availability_topic": "availability_topic2" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('binary_sensor.milk') + assert state is None + + +async def test_discovery_unique_id(hass, mqtt_mock, caplog): + """Test unique id option only creates one sensor per unique_id.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "state_topic": "test_topic",' + ' "unique_id": "TOTALLY_UNIQUE" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "state_topic": "test_topic",' + ' "unique_id": "TOTALLY_DIFFERENT" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get('binary_sensor.beer') + assert state is not None + assert state.name == 'Beer' + + state = hass.states.get('binary_sensor.milk') + assert state is not None + assert state.name == 'Milk' + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index f448bcc47a2..a1f97398616 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -1,10 +1,9 @@ """The tests for the Template Binary sensor platform.""" -import asyncio from datetime import timedelta import unittest from unittest import mock -from homeassistant.const import MATCH_ALL +from homeassistant.const import MATCH_ALL, EVENT_HOMEASSISTANT_START from homeassistant import setup from homeassistant.components.binary_sensor import template from homeassistant.exceptions import TemplateError @@ -182,7 +181,7 @@ class TestBinarySensorTemplate(unittest.TestCase): self.hass.states.set('sensor.any_state', 'update') self.hass.block_till_done() - assert len(_async_render.mock_calls) > init_calls + assert len(_async_render.mock_calls) == init_calls def test_attributes(self): """Test the attributes.""" @@ -252,8 +251,7 @@ class TestBinarySensorTemplate(unittest.TestCase): run_callback_threadsafe(self.hass.loop, vs.async_check_state).result() -@asyncio.coroutine -def test_template_delay_on(hass): +async def test_template_delay_on(hass): """Test binary sensor template delay on.""" config = { 'binary_sensor': { @@ -269,51 +267,50 @@ def test_template_delay_on(hass): }, }, } - yield from setup.async_setup_component(hass, 'binary_sensor', config) - yield from hass.async_start() + await setup.async_setup_component(hass, 'binary_sensor', config) + await hass.async_start() hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' # check with time changes hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' -@asyncio.coroutine -def test_template_delay_off(hass): +async def test_template_delay_off(hass): """Test binary sensor template delay off.""" config = { 'binary_sensor': { @@ -330,44 +327,110 @@ def test_template_delay_off(hass): }, } hass.states.async_set('sensor.test_state', 'on') - yield from setup.async_setup_component(hass, 'binary_sensor', config) - yield from hass.async_start() + await setup.async_setup_component(hass, 'binary_sensor', config) + await hass.async_start() hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'off' # check with time changes hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' hass.states.async_set('sensor.test_state', 'off') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' hass.states.async_set('sensor.test_state', 'on') - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' future = dt_util.utcnow() + timedelta(seconds=5) async_fire_time_changed(hass, future) - yield from hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('binary_sensor.test') assert state.state == 'on' + + +async def test_no_update_template_match_all(hass, caplog): + """Test that we do not update sensors that match on all.""" + hass.states.async_set('binary_sensor.test_sensor', 'true') + + await setup.async_setup_component(hass, 'binary_sensor', { + 'binary_sensor': { + 'platform': 'template', + 'sensors': { + 'all_state': { + 'value_template': '{{ "true" }}', + }, + 'all_icon': { + 'value_template': + '{{ states.binary_sensor.test_sensor.state }}', + 'icon_template': '{{ 1 + 1 }}', + }, + 'all_entity_picture': { + 'value_template': + '{{ states.binary_sensor.test_sensor.state }}', + 'entity_picture_template': '{{ 1 + 1 }}', + }, + } + } + }) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + assert ('Template binary sensor all_state has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the value template') in caplog.text + assert ('Template binary sensor all_icon has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the icon template') in caplog.text + assert ('Template binary sensor all_entity_picture has no entity ids ' + 'configured to track nor were we able to extract the entities to ' + 'track from the entity_picture template') in caplog.text + + assert hass.states.get('binary_sensor.all_state').state == 'off' + assert hass.states.get('binary_sensor.all_icon').state == 'off' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'off' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'on' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'on' + + hass.states.async_set('binary_sensor.test_sensor', 'false') + await hass.async_block_till_done() + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'on' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'on' + + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_state') + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_icon') + await hass.helpers.entity_component.async_update_entity( + 'binary_sensor.all_entity_picture') + + assert hass.states.get('binary_sensor.all_state').state == 'on' + assert hass.states.get('binary_sensor.all_icon').state == 'off' + assert hass.states.get('binary_sensor.all_entity_picture').state == 'off' diff --git a/tests/components/binary_sensor/test_zwave.py b/tests/components/binary_sensor/test_zwave.py index a5dabf6953a..f33e8a83e1e 100644 --- a/tests/components/binary_sensor/test_zwave.py +++ b/tests/components/binary_sensor/test_zwave.py @@ -1,5 +1,4 @@ """Test Z-Wave binary sensors.""" -import asyncio import datetime from unittest.mock import patch @@ -71,8 +70,7 @@ def test_binary_sensor_value_changed(mock_openzwave): assert device.is_on -@asyncio.coroutine -def test_trigger_sensor_value_changed(hass, mock_openzwave): +async def test_trigger_sensor_value_changed(hass, mock_openzwave): """Test value changed for trigger sensor.""" node = MockNode( manufacturer_id='013c', product_type='0002', product_id='0002') @@ -84,13 +82,13 @@ def test_trigger_sensor_value_changed(hass, mock_openzwave): assert not device.is_on value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.invalidate_after is None device.hass = hass value.data = True - yield from hass.async_add_job(value_changed, value) + await hass.async_add_job(value_changed, value) assert device.is_on test_time = device.invalidate_after - datetime.timedelta(seconds=1) diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py index 3d30a21504a..8d2346260d9 100644 --- a/tests/components/climate/test_generic_thermostat.py +++ b/tests/components/climate/test_generic_thermostat.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_OFF, STATE_IDLE, TEMP_CELSIUS, + TEMP_FAHRENHEIT, ATTR_TEMPERATURE ) from homeassistant import loader @@ -1074,6 +1075,37 @@ async def test_turn_off_when_off(hass, setup_comp_9): state_cool.attributes.get('operation_mode') +@pytest.fixture +def setup_comp_10(hass): + """Initialize components.""" + hass.config.temperature_unit = TEMP_FAHRENHEIT + assert hass.loop.run_until_complete(async_setup_component( + hass, climate.DOMAIN, {'climate': { + 'platform': 'generic_thermostat', + 'name': 'test', + 'cold_tolerance': 0.3, + 'hot_tolerance': 0.3, + 'target_temp': 25, + 'heater': ENT_SWITCH, + 'target_sensor': ENT_SENSOR, + 'min_cycle_duration': datetime.timedelta(minutes=15), + 'keep_alive': datetime.timedelta(minutes=10), + 'precision': 0.1 + }})) + + +async def test_precision(hass, setup_comp_10): + """Test that setting precision to tenths works as intended.""" + common.async_set_operation_mode(hass, STATE_OFF) + await hass.async_block_till_done() + await hass.services.async_call('climate', SERVICE_TURN_OFF) + await hass.async_block_till_done() + common.async_set_temperature(hass, 23.27) + await hass.async_block_till_done() + state = hass.states.get(ENTITY) + assert 23.3 == state.attributes.get('temperature') + + async def test_custom_setup_params(hass): """Test the setup with custom parameters.""" result = await async_setup_component( diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 108e5c45137..ba63e43d091 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -2,6 +2,7 @@ from unittest.mock import patch from homeassistant.setup import async_setup_component from homeassistant.components import cloud +from homeassistant.components.cloud import const from jose import jwt @@ -24,9 +25,10 @@ def mock_cloud(hass, config={}): def mock_cloud_prefs(hass, prefs={}): """Fixture for cloud component.""" prefs_to_set = { - cloud.STORAGE_ENABLE_ALEXA: True, - cloud.STORAGE_ENABLE_GOOGLE: True, + const.PREF_ENABLE_ALEXA: True, + const.PREF_ENABLE_GOOGLE: True, + const.PREF_GOOGLE_ALLOW_UNLOCK: True, } prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN]._prefs = prefs_to_set + hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set return prefs_to_set diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index a8128c8d3e0..4abf5b8501d 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -6,7 +6,9 @@ import pytest from jose import jwt from homeassistant.components.cloud import ( - DOMAIN, auth_api, iot, STORAGE_ENABLE_GOOGLE, STORAGE_ENABLE_ALEXA) + DOMAIN, auth_api, iot) +from homeassistant.components.cloud.const import ( + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -350,7 +352,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'logged_in': True, 'email': 'hello@home-assistant.io', 'cloud': 'connected', - 'alexa_enabled': True, + 'prefs': mock_cloud_fixture, 'alexa_entities': { 'include_domains': [], 'include_entities': ['light.kitchen', 'switch.ac'], @@ -358,7 +360,6 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'exclude_entities': [], }, 'alexa_domains': ['switch'], - 'google_enabled': True, 'google_entities': { 'include_domains': ['light'], 'include_entities': [], @@ -505,8 +506,9 @@ 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): """Test updating preference.""" - assert setup_api[STORAGE_ENABLE_GOOGLE] - assert setup_api[STORAGE_ENABLE_ALEXA] + 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' @@ -517,9 +519,11 @@ async def test_websocket_update_preferences(hass, hass_ws_client, 'type': 'cloud/update_prefs', 'alexa_enabled': False, 'google_enabled': False, + 'google_allow_unlock': False, }) response = await client.receive_json() assert response['success'] - assert not setup_api[STORAGE_ENABLE_GOOGLE] - assert not setup_api[STORAGE_ENABLE_ALEXA] + assert not setup_api[PREF_ENABLE_GOOGLE] + assert not setup_api[PREF_ENABLE_ALEXA] + assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 07ec1851fbe..c900fc3a7a8 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -7,8 +7,9 @@ import pytest from homeassistant.setup import async_setup_component from homeassistant.components.cloud import ( - Cloud, iot, auth_api, MODE_DEV, STORAGE_ENABLE_ALEXA, - STORAGE_ENABLE_GOOGLE) + 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 @@ -308,7 +309,7 @@ def test_handler_alexa(hass): @asyncio.coroutine def test_handler_alexa_disabled(hass, mock_cloud_fixture): """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_ALEXA] = False + mock_cloud_fixture[PREF_ENABLE_ALEXA] = False resp = yield from iot.async_handle_alexa( hass, hass.data['cloud'], @@ -326,6 +327,8 @@ def test_handler_google_actions(hass): '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()): @@ -375,7 +378,7 @@ def test_handler_google_actions(hass): async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[STORAGE_ENABLE_GOOGLE] = False + mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False with patch('homeassistant.components.cloud.Cloud.async_start', return_value=mock_coro()): diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 252d0b1d872..97f2044baea 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( @@ -71,9 +72,24 @@ def hass_ws_client(aiohttp_client): @pytest.fixture -def hass_access_token(hass): +def hass_access_token(hass, hass_admin_user): """Return an access token to access Home Assistant.""" - user = MockUser().add_to_hass(hass) refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(user, CLIENT_ID)) + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) yield hass.auth.async_create_access_token(refresh_token) + + +@pytest.fixture +def hass_admin_user(hass): + """Return a Home Assistant admin user.""" + admin_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_ADMIN)) + return MockUser(groups=[admin_group]).add_to_hass(hass) + + +@pytest.fixture +def hass_read_only_user(hass): + """Return a Home Assistant read only user.""" + read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group( + GROUP_ID_READ_ONLY)) + return MockUser(groups=[read_only_group]).add_to_hass(hass) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 78ca72dd1e4..c8411bf2fde 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -234,7 +234,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert int(state.state) == 0 -async def test_counter_context(hass): +async def test_counter_context(hass, hass_admin_user): """Test that counter context works.""" assert await async_setup_component(hass, 'counter', { 'counter': { @@ -247,9 +247,9 @@ async def test_counter_context(hass): await hass.services.async_call('counter', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('counter.test') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 81c0848c4c5..26204ce6ebd 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -308,18 +308,81 @@ class TestCoverMQTT(unittest.TestCase): 'cover.test').attributes['current_position'] assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', '101') - self.hass.block_till_done() - current_cover_position = self.hass.states.get( - 'cover.test').attributes['current_position'] - assert 50 == current_cover_position - fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') self.hass.block_till_done() current_cover_position = self.hass.states.get( 'cover.test').attributes['current_position'] assert 50 == current_cover_position + fire_mqtt_message(self.hass, 'get-position-topic', '101') + self.hass.block_till_done() + current_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 100 == current_cover_position + + def test_current_cover_position_inverted(self): + """Test the current cover position.""" + assert setup_component(self.hass, cover.DOMAIN, { + cover.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'position_topic': 'get-position-topic', + 'command_topic': 'command-topic', + 'position_open': 0, + 'position_closed': 100, + 'payload_open': 'OPEN', + 'payload_close': 'CLOSE', + 'payload_stop': 'STOP' + } + }) + + state_attributes_dict = self.hass.states.get( + 'cover.test').attributes + assert not ('current_position' in state_attributes_dict) + assert not ('current_tilt_position' in state_attributes_dict) + assert not (4 & self.hass.states.get( + 'cover.test').attributes['supported_features'] == 4) + + fire_mqtt_message(self.hass, 'get-position-topic', '100') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '0') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 100 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '50') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', 'non-numeric') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 50 == current_percentage_cover_position + assert STATE_OPEN == self.hass.states.get( + 'cover.test').state + + fire_mqtt_message(self.hass, 'get-position-topic', '101') + self.hass.block_till_done() + current_percentage_cover_position = self.hass.states.get( + 'cover.test').attributes['current_position'] + assert 0 == current_percentage_cover_position + assert STATE_CLOSED == self.hass.states.get( + 'cover.test').state + def test_set_cover_position(self): """Test setting cover position.""" assert setup_component(self.hass, cover.DOMAIN, { diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 20b7a88bc05..9e1d6a2fca1 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for deCONZ config flow.""" -from unittest.mock import patch import pytest import voluptuous as vol @@ -45,7 +44,7 @@ async def test_flow_already_registered_bridge(hass): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'abort' @@ -55,8 +54,9 @@ async def test_flow_no_discovered_bridges(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() - assert result['type'] == 'abort' + result = await flow.async_step_init() + assert result['type'] == 'form' + assert result['step_id'] == 'user' async def test_flow_one_bridge_discovered(hass, aioclient_mock): @@ -67,7 +67,7 @@ async def test_flow_one_bridge_discovered(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'form' assert result['step_id'] == 'link' @@ -81,9 +81,9 @@ async def test_flow_two_bridges_discovered(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass - result = await flow.async_step_user() + result = await flow.async_step_init() assert result['type'] == 'form' - assert result['step_id'] == 'user' + assert result['step_id'] == 'init' with pytest.raises(vol.Invalid): assert result['data_schema']({'host': '0.0.0.0'}) @@ -101,12 +101,26 @@ async def test_flow_two_bridges_selection(hass, aioclient_mock): {'bridgeid': 'id2', 'host': '5.6.7.8', 'port': 80} ] - result = await flow.async_step_user(user_input={'host': '1.2.3.4'}) + result = await flow.async_step_init(user_input={'host': '1.2.3.4'}) assert result['type'] == 'form' assert result['step_id'] == 'link' assert flow.deconz_config['host'] == '1.2.3.4' +async def test_flow_manual_configuration(hass, aioclient_mock): + """Test config flow with manual input.""" + aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[]) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + + user_input = {'host': '1.2.3.4', 'port': 80} + + result = await flow.async_step_init(user_input) + assert result['type'] == 'form' + assert result['step_id'] == 'link' + assert flow.deconz_config == user_input + + async def test_link_no_api_key(hass, aioclient_mock): """Test config flow should abort if no API key was possible to retrieve.""" aioclient_mock.post('http://1.2.3.4:80/api', json=[]) @@ -138,57 +152,14 @@ async def test_link_already_registered_bridge(hass): async def test_bridge_discovery(hass): - """Test a bridge being discovered with no additional config file.""" + """Test a bridge being discovered.""" flow = config_flow.DeconzFlowHandler() flow.hass = hass - with patch.object(config_flow, 'load_json', return_value={}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) - - assert result['type'] == 'form' - assert result['step_id'] == 'link' - - -async def test_bridge_discovery_config_file(hass): - """Test a bridge being discovered with a corresponding config file.""" - flow = config_flow.DeconzFlowHandler() - flow.hass = hass - with patch.object(config_flow, 'load_json', - return_value={'host': '1.2.3.4', - 'port': 8080, - 'api_key': '1234567890ABCDEF'}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) - - assert result['type'] == 'create_entry' - assert result['title'] == 'deCONZ-id' - assert result['data'] == { - 'bridgeid': 'id', + result = await flow.async_step_discovery({ 'host': '1.2.3.4', 'port': 80, - 'api_key': '1234567890ABCDEF', - 'allow_clip_sensor': True, - 'allow_deconz_groups': True - } - - -async def test_bridge_discovery_other_config_file(hass): - """Test a bridge being discovered with another bridges config file.""" - flow = config_flow.DeconzFlowHandler() - flow.hass = hass - with patch.object(config_flow, 'load_json', - return_value={'host': '5.6.7.8', 'api_key': '5678'}): - result = await flow.async_step_discovery({ - 'host': '1.2.3.4', - 'port': 80, - 'serial': 'id' - }) + 'serial': 'id' + }) assert result['type'] == 'form' assert result['step_id'] == 'link' diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py new file mode 100644 index 00000000000..3411f96b981 --- /dev/null +++ b/tests/components/deconz/test_gateway.py @@ -0,0 +1,222 @@ +"""Test deCONZ gateway.""" +from unittest.mock import Mock, patch + +from homeassistant.components.deconz import gateway + +from tests.common import mock_coro + +ENTRY_CONFIG = { + "host": "1.2.3.4", + "port": 80, + "api_key": "1234567890ABCDEF", + "bridgeid": "0123456789ABCDEF", + "allow_clip_sensor": True, + "allow_deconz_groups": True, +} + + +async def test_gateway_setup(): + """Successful setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.async_add_remote.return_value = Mock() + api.sensors = {} + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \ + patch.object( + gateway, 'async_dispatcher_connect', return_value=Mock()): + assert await deconz_gateway.async_setup() is True + + assert deconz_gateway.api is api + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'binary_sensor') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'cover') + assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \ + (entry, 'light') + assert hass.config_entries.async_forward_entry_setup.mock_calls[3][1] == \ + (entry, 'scene') + assert hass.config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'sensor') + assert hass.config_entries.async_forward_entry_setup.mock_calls[5][1] == \ + (entry, 'switch') + assert len(api.start.mock_calls) == 1 + + +async def test_gateway_retry(): + """Retry setup.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(False)): + assert await deconz_gateway.async_setup() is False + + +async def test_connection_status(hass): + """Make sure that connection status triggers a dispatcher send.""" + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send: + deconz_gateway.async_connection_status_callback(True) + + await hass.async_block_till_done() + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_add_device(hass): + """Successful retry setup.""" + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send: + deconz_gateway.async_add_device_callback('sensor', Mock()) + + await hass.async_block_till_done() + assert len(mock_dispatch_send.mock_calls) == 1 + assert len(mock_dispatch_send.mock_calls[0]) == 3 + + +async def test_add_remote(): + """Successful add remote.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + remote = Mock() + remote.name = 'name' + remote.type = 'ZHASwitch' + remote.register_async_callback = Mock() + + deconz_gateway = gateway.DeconzGateway(hass, entry) + deconz_gateway.async_add_remote([remote]) + + assert len(deconz_gateway.events) == 1 + + +async def test_shutdown(): + """Successful shutdown.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + deconz_gateway.api = Mock() + deconz_gateway.shutdown(None) + + assert len(deconz_gateway.api.close.mock_calls) == 1 + + +async def test_reset_cancel_retry(): + """Verify async reset can handle a scheduled retry.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(False)): + assert await deconz_gateway.async_setup() is False + + assert deconz_gateway._cancel_retry_setup is not None + + assert await deconz_gateway.async_reset() is True + + +async def test_reset_after_successful_setup(): + """Verify that reset works on a setup component.""" + hass = Mock() + entry = Mock() + entry.data = ENTRY_CONFIG + api = Mock() + api.async_add_remote.return_value = Mock() + api.sensors = {} + + deconz_gateway = gateway.DeconzGateway(hass, entry) + + with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \ + patch.object( + gateway, 'async_dispatcher_connect', return_value=Mock()): + assert await deconz_gateway.async_setup() is True + + listener = Mock() + deconz_gateway.listeners = [listener] + event = Mock() + event.async_will_remove_from_hass = Mock() + deconz_gateway.events = [event] + deconz_gateway.deconz_ids = {'key': 'value'} + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + assert await deconz_gateway.async_reset() is True + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 + + assert len(listener.mock_calls) == 1 + assert len(deconz_gateway.listeners) == 0 + + assert len(event.async_will_remove_from_hass.mock_calls) == 1 + assert len(deconz_gateway.events) == 0 + + assert len(deconz_gateway.deconz_ids) == 0 + + +async def test_get_gateway(hass): + """Successful call.""" + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + + +async def test_get_gateway_fails(hass): + """Failed call.""" + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(False)): + assert await gateway.get_gateway( + hass, ENTRY_CONFIG, Mock(), Mock()) is False + + +async def test_create_event(): + """Successfully created a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + + assert event._id == 'name' + + +async def test_update_event(): + """Successfully update a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + event.async_update_callback({'state': True}) + + assert len(hass.bus.async_fire.mock_calls) == 1 + + +async def test_remove_event(): + """Successfully update a deCONZ event.""" + hass = Mock() + remote = Mock() + remote.name = 'Name' + + event = gateway.DeconzEvent(hass, remote) + event.async_will_remove_from_hass() + + assert event._device is None diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 3453dd86c12..b83756f6ebb 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -21,8 +21,7 @@ CONFIG = { async def test_config_with_host_passed_to_config_entry(hass): """Test that configured options for a host are loaded via config entry.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', return_value={}): + patch.object(deconz, 'configured_hosts', return_value=[]): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: { deconz.CONF_HOST: '1.2.3.4', @@ -33,24 +32,10 @@ async def test_config_with_host_passed_to_config_entry(hass): assert len(mock_config_entries.flow.mock_calls) == 2 -async def test_config_file_passed_to_config_entry(hass): - """Test that configuration file for a host are loaded via config entry.""" - with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', - return_value={'host': '1.2.3.4'}): - assert await async_setup_component(hass, deconz.DOMAIN, { - deconz.DOMAIN: {} - }) is True - # Import flow started - assert len(mock_config_entries.flow.mock_calls) == 2 - - async def test_config_without_host_not_passed_to_config_entry(hass): """Test that a configuration without a host does not initiate an import.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ - patch.object(deconz, 'configured_hosts', return_value=[]), \ - patch.object(deconz, 'load_json', return_value={}): + patch.object(deconz, 'configured_hosts', return_value=[]): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: {} }) is True @@ -62,8 +47,7 @@ async def test_config_already_registered_not_passed_to_config_entry(hass): """Test that an already registered host does not initiate an import.""" with patch.object(hass, 'config_entries') as mock_config_entries, \ patch.object(deconz, 'configured_hosts', - return_value=['1.2.3.4']), \ - patch.object(deconz, 'load_json', return_value={}): + return_value=['1.2.3.4']): assert await async_setup_component(hass, deconz.DOMAIN, { deconz.DOMAIN: { deconz.CONF_HOST: '1.2.3.4', diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 09f14dc9700..e6f7a582e70 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -1,23 +1,12 @@ """The tests for the ASUSWRT device tracker platform.""" -import os -from datetime import timedelta -import unittest -from unittest import mock +from homeassistant.setup import async_setup_component -from homeassistant.setup import setup_component -from homeassistant.components import device_tracker -from homeassistant.components.device_tracker import ( - CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_NEW_DEVICE_DEFAULTS, - CONF_AWAY_HIDE) -from homeassistant.components.device_tracker.asuswrt import ( - CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT) +from homeassistant.components.asuswrt import ( + CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT, DATA_ASUSWRT) from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) -import pytest -from tests.common import ( - get_test_home_assistant, get_test_config_dir, assert_setup_component, - mock_component) +from tests.common import MockDependency, mock_coro_func FAKEFILE = None @@ -31,77 +20,31 @@ VALID_CONFIG_ROUTER_SSH = {DOMAIN: { }} -def setup_module(): - """Set up the test module.""" - global FAKEFILE - FAKEFILE = get_test_config_dir('fake_file') - with open(FAKEFILE, 'w') as out: - out.write(' ') +async def test_password_or_pub_key_required(hass): + """Test creating an AsusWRT scanner without a pass or pubkey.""" + with MockDependency('aioasuswrt.asuswrt')as mocked_asus: + mocked_asus.AsusWrt().connection.async_connect = mock_coro_func() + mocked_asus.AsusWrt().is_connected = False + result = await async_setup_component( + hass, DOMAIN, {DOMAIN: { + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user' + }}) + assert not result -def teardown_module(): - """Tear down the module.""" - os.remove(FAKEFILE) - - -@pytest.mark.skip( - reason="These tests are performing actual failing network calls. They " - "need to be cleaned up before they are re-enabled. They're frequently " - "failing in Travis.") -class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase): - """Tests for the ASUSWRT device tracker platform.""" - - hass = None - - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_component(self.hass, 'zone') - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_password_or_pub_key_required(self): - """Test creating an AsusWRT scanner without a pass or pubkey.""" - with assert_setup_component(0, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'asuswrt', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PROTOCOL: 'ssh' - }}) - - @mock.patch( - 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', - return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): - """Test creating an AsusWRT scanner with a password and no pubkey.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'asuswrt', +async def test_get_scanner_with_password_no_pubkey(hass): + """Test creating an AsusWRT scanner with a password and no pubkey.""" + with MockDependency('aioasuswrt.asuswrt')as mocked_asus: + mocked_asus.AsusWrt().connection.async_connect = mock_coro_func() + mocked_asus.AsusWrt( + ).connection.async_get_connected_devices = mock_coro_func( + return_value={}) + result = await async_setup_component( + hass, DOMAIN, {DOMAIN: { CONF_HOST: 'fake_host', CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } - } - } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_MODE] = 'router' - conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh' - conf_dict[DOMAIN][CONF_PORT] = 22 - assert asuswrt_mock.call_count == 1 - assert asuswrt_mock.call_args == mock.call(conf_dict[DOMAIN]) + CONF_PASSWORD: '4321' + }}) + assert result + assert hass.data[DATA_ASUSWRT] is not None diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py deleted file mode 100644 index 457ef6b47d0..00000000000 --- a/tests/components/device_tracker/test_ddwrt.py +++ /dev/null @@ -1,254 +0,0 @@ -"""The tests for the DD-WRT device tracker platform.""" -import os -import unittest -from unittest import mock -import logging -import re -import requests -import requests_mock - -import pytest - -from homeassistant import config -from homeassistant.setup import setup_component -from homeassistant.components import device_tracker -from homeassistant.const import ( - CONF_PLATFORM, CONF_HOST, CONF_PASSWORD, CONF_USERNAME) -from homeassistant.components.device_tracker import DOMAIN -from homeassistant.util import slugify - -from tests.common import ( - get_test_home_assistant, assert_setup_component, load_fixture, - mock_component) - -from ...test_util.aiohttp import mock_aiohttp_client - -TEST_HOST = '127.0.0.1' -_LOGGER = logging.getLogger(__name__) - - -@pytest.mark.skip -class TestDdwrt(unittest.TestCase): - """Tests for the Ddwrt device tracker platform.""" - - hass = None - - def run(self, result=None): - """Mock out http calls to macvendor API for whole test suite.""" - with mock_aiohttp_client() as aioclient_mock: - macvendor_re = re.compile('http://api.macvendors.com/.*') - aioclient_mock.get(macvendor_re, text='') - super().run(result) - - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_component(self.hass, 'zone') - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_login_failed(self, mock_error): - """Create a Ddwrt scanner with wrong credentials.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - status_code=401) - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Failed to authenticate' in \ - str(mock_error.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_invalid_response(self, mock_error): - """Test error handling when response has an error status.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - status_code=444) - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Invalid response from DD-WRT' in \ - str(mock_error.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker._LOGGER.error') - @mock.patch('homeassistant.components.device_tracker.' - 'ddwrt.DdWrtDeviceScanner.get_ddwrt_data', return_value=None) - def test_no_response(self, data_mock, error_mock): - """Create a Ddwrt scanner with no response in init, should fail.""" - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - assert 'Error setting up platform' in \ - str(error_mock.call_args_list[-1]) - - @mock.patch('homeassistant.components.device_tracker.ddwrt.requests.get', - side_effect=requests.exceptions.Timeout) - @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error') - def test_get_timeout(self, mock_error, mock_request): - """Test get Ddwrt data with request time out.""" - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - assert 'Connection to the router timed out' in \ - str(mock_error.call_args_list[-1]) - - def test_scan_devices(self): - """Test creating device info (MAC, name) from response. - - The created known_devices.yaml device info is compared - to the DD-WRT Lan Status request response fixture. - This effectively checks the data parsing functions. - """ - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=status_lan) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - for device in devices: - assert devices[device]['mac'] in status_lan - assert slugify(devices[device]['name']) in status_lan - - def test_device_name_no_data(self): - """Test creating device info (MAC only) when no response.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, text=None) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - for device in devices: - _LOGGER.error(devices[device]) - assert devices[device]['mac'] in status_lan - - def test_device_name_no_dhcp(self): - """Test creating device info (MAC) when missing dhcp response.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt'). - replace('dhcp_leases', 'missing')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - self.hass.block_till_done() - - path = self.hass.config.path(device_tracker.YAML_DEVICES) - devices = config.load_yaml_config_file(path) - status_lan = load_fixture('Ddwrt_Status_Lan.txt') - for device in devices: - _LOGGER.error(devices[device]) - assert devices[device]['mac'] in status_lan - - def test_update_no_data(self): - """Test error handling of no response when active devices checked.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - # First request has to work to set up connection - [{'text': load_fixture('Ddwrt_Status_Wireless.txt')}, - # Second request to get active devices fails - {'text': None}]) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) - - def test_update_wrong_data(self): - """Test error handling of bad response when active devices checked.""" - with requests_mock.Mocker() as mock_request: - mock_request.register_uri( - 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Wireless.txt'). - replace('active_wireless', 'missing')) - mock_request.register_uri( - 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, - text=load_fixture('Ddwrt_Status_Lan.txt')) - - with assert_setup_component(1, DOMAIN): - assert setup_component( - self.hass, DOMAIN, {DOMAIN: { - CONF_PLATFORM: 'ddwrt', - CONF_HOST: TEST_HOST, - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: '0' - }}) diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index dcd66ed2a7c..6f457f30ed0 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1,17 +1,14 @@ """The tests for the Owntracks device tracker.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest from tests.common import ( - assert_setup_component, fire_mqtt_message, mock_coro, mock_component, - get_test_home_assistant, mock_mqtt_component) -import homeassistant.components.device_tracker.owntracks as owntracks -from homeassistant.setup import setup_component -from homeassistant.components import device_tracker -from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME -from homeassistant.util.async_ import run_coroutine_threadsafe + async_fire_mqtt_message, mock_coro, mock_component, + async_mock_mqtt_component, MockConfigEntry) +from homeassistant.components import owntracks +from homeassistant.setup import async_setup_component +from homeassistant.const import STATE_NOT_HOME USER = 'greg' DEVICE = 'phone' @@ -275,982 +272,1021 @@ BAD_JSON_PREFIX = '--$this is bad json#--' BAD_JSON_SUFFIX = '** and it ends here ^^' -# def raise_on_not_implemented(hass, context, message): -def raise_on_not_implemented(): - """Throw NotImplemented.""" - raise NotImplementedError("oopsie") +@pytest.fixture +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) -class BaseMQTT(unittest.TestCase): - """Base MQTT assert functions.""" + hass.states.async_set( + 'zone.inner_2', 'zoning', INNER_ZONE) - hass = None + hass.states.async_set( + 'zone.outer', 'zoning', OUTER_ZONE) - def send_message(self, topic, message, corrupt=False): - """Test the sending of a message.""" - str_message = json.dumps(message) - if corrupt: - mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX - else: - mod_message = str_message - fire_mqtt_message(self.hass, topic, mod_message) - self.hass.block_till_done() - def assert_location_state(self, location): - """Test the assertion of a location state.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.state == location +async def setup_owntracks(hass, config, + ctx_cls=owntracks.OwnTracksContext): + """Set up OwnTracks.""" + await async_mock_mqtt_component(hass) - def assert_location_latitude(self, latitude): - """Test the assertion of a location latitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('latitude') == latitude + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) - def assert_location_longitude(self, longitude): - """Test the assertion of a location longitude.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('longitude') == longitude + with patch('homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])), \ + patch('homeassistant.components.device_tracker.' + 'load_yaml_config_file', return_value=mock_coro({})), \ + patch.object(owntracks, 'OwnTracksContext', ctx_cls): + assert await async_setup_component( + hass, 'owntracks', {'owntracks': config}) - def assert_location_accuracy(self, accuracy): - """Test the assertion of a location accuracy.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('gps_accuracy') == accuracy - def assert_location_source_type(self, source_type): - """Test the assertion of source_type.""" - state = self.hass.states.get(DEVICE_TRACKER_STATE) - assert state.attributes.get('source_type') == source_type +@pytest.fixture +def context(hass, setup_comp): + """Set up the mocked context.""" + patcher = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patcher.start() + orig_context = owntracks.OwnTracksContext -class TestDeviceTrackerOwnTracks(BaseMQTT): - """Test the OwnTrack sensor.""" + context = None - # pylint: disable=invalid-name - def setup_method(self, _): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') + def store_context(*args): + nonlocal context + context = orig_context(*args) + return context - patcher = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patcher.start() - self.addCleanup(patcher.stop) - - orig_context = owntracks.OwnTracksContext - - def store_context(*args): - self.context = orig_context(*args) - return self.context - - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])), \ - patch('homeassistant.components.device_tracker.' - 'load_yaml_config_file', return_value=mock_coro({})), \ - patch.object(owntracks, 'OwnTracksContext', store_context), \ - assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MAX_GPS_ACCURACY: 200, - CONF_WAYPOINT_IMPORT: True, - CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] - }}) - - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.inner_2', 'zoning', INNER_ZONE) - - self.hass.states.set( - 'zone.outer', 'zoning', OUTER_ZONE) - - # Clear state between tests - # NB: state "None" is not a state that is created by Device - # so when we compare state to None in the tests this - # is really checking that it is still in its original - # test case state. See Device.async_update. - self.hass.states.set(DEVICE_TRACKER_STATE, None) - - def teardown_method(self, _): - """Stop everything that was started.""" - self.hass.stop() - - def assert_mobile_tracker_state(self, location, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker state.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.state == location - - def assert_mobile_tracker_latitude(self, latitude, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker latitude.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('latitude') == latitude - - def assert_mobile_tracker_accuracy(self, accuracy, beacon=IBEACON_DEVICE): - """Test the assertion of a mobile beacon tracker accuracy.""" - dev_id = MOBILE_BEACON_FMT.format(beacon) - state = self.hass.states.get(dev_id) - assert state.attributes.get('gps_accuracy') == accuracy - - def test_location_invalid_devid(self): # pylint: disable=invalid-name - """Test the update of a location.""" - self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE) - state = self.hass.states.get('device_tracker.paulus_nexus5x') - assert state.state == 'outer' - - def test_location_update(self): - """Test the update of a location.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_location_inaccurate_gps(self): - """Test the location for inaccurate GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - def test_location_zero_accuracy_gps(self): - """Ignore the location for zero accuracy GPS information.""" - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) - - # Ignored inaccurate GPS. Location remains at previous. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_longitude(LOCATION_MESSAGE['lon']) - - # ------------------------------------------------------------------------ - # GPS based event entry / exit testing - - def test_event_gps_entry_exit(self): - """Test the entry event.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # Exit switches back to GPS - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Now sending a location update moves me again. - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_gps_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_inaccurate(self): - """Test the event for inaccurate entry.""" - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) - - # I enter the zone even though the message GPS was inaccurate. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_gps_entry_exit_inaccurate(self): - """Test the event for inaccurate exit.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) - - # Exit doesn't use inaccurate gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_entry_exit_zero_accuracy(self): - """Test entry/exit events with accuracy zero.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) - - # Exit doesn't use zero gps - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # But does exit region correctly - assert not self.context.regions_entered[USER] - - def test_event_gps_exit_outside_zone_sets_away(self): - """Test the event for exit zone.""" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Exit message far away GPS location - message = build_message( - {'lon': 90.0, - 'lat': 90.0}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Exit forces zone change to away - self.assert_location_state(STATE_NOT_HOME) - - def test_event_gps_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_accuracy(REGION_GPS_LEAVE_MESSAGE['acc']) - self.assert_location_state('outer') - - def test_event_gps_entry_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_ENTER_MESSAGE['lat']) - self.assert_location_state('inner') - - def test_event_gps_exit_unknown_zone(self): - """Test the event for unknown zone.""" - # Just treat as location update - message = build_message( - {'desc': "unknown"}, - REGION_GPS_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - - def test_event_entry_zone_loading_dash(self): - """Test the event for zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - message = build_message( - {'desc': "-inner"}, - REGION_GPS_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - def test_events_only_on(self): - """Test events_only config suppresses location updates.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = True - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Ignored location update. Location remains at previous. - self.assert_location_state(STATE_NOT_HOME) - - def test_events_only_off(self): - """Test when events_only is False.""" - # Sending a location message that is not home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_location_state(STATE_NOT_HOME) - - self.context.events_only = False - - # Enter and Leave messages - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) - self.assert_location_state('outer') - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_state(STATE_NOT_HOME) - - # Sending a location message that is inside outer zone - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Location update processed - self.assert_location_state('outer') - - def test_event_source_type_entry_exit(self): - """Test the entry and exit events of source type.""" - # Entering the owntracks circular region named "inner" - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - # source_type should be gps when entering using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) - - # We should be able to enter a beacon zone even inside a gps zone - self.assert_location_source_type('bluetooth_le') - - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - - # source_type should be gps when leaving using gps. - self.assert_location_source_type('gps') - - # owntracks shouldn't send beacon events with acc = 0 - self.send_message(EVENT_TOPIC, build_message( - {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) - - self.assert_location_source_type('bluetooth_le') - - # Region Beacon based event entry / exit testing - - def test_event_region_entry_exit(self): - """Test the entry event.""" - # Seeing a beacon named "inner" - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - - # Enter uses the zone's gps co-ords - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # Updates ignored when in a zone - # note that LOCATION_MESSAGE is actually pretty far - # from INNER_ZONE and has good accuracy. I haven't - # received a transition message though so I'm still - # associated with the inner zone regardless of GPS. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # Exit switches back to GPS but the beacon has no coords - # so I am still located at the center of the inner region - # until I receive a location update. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - # Left clean zone state - assert not self.context.regions_entered[USER] - - # Now sending a location update moves me again. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - self.assert_location_accuracy(LOCATION_MESSAGE['acc']) - - def test_event_region_with_spaces(self): - """Test the entry event.""" - message = build_message({'desc': "inner 2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner 2') - - message = build_message({'desc': "inner 2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # Left clean zone state - assert not self.context.regions_entered[USER] - - def test_event_region_entry_exit_right_order(self): - """Test the event for ordering.""" - # Enter inner zone - # Set location to the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # See 'inner' region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # See 'inner_2' region beacon - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'inner' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # Exit inner - should be in 'outer' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner') - - def test_event_region_entry_exit_wrong_order(self): - """Test the event for wrong order.""" - # Enter inner zone - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.assert_location_state('inner') - - # Enter inner2 zone - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner_2') - - # Exit inner - should still be in 'inner_2' - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.assert_location_state('inner_2') - - # Exit inner_2 - should be in 'outer' - message = build_message( - {'desc': "inner_2"}, - REGION_BEACON_LEAVE_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # I have not had an actual location update yet and my - # coordinates are set to the center of the last region I - # entered which puts me in the inner_2 zone. - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_accuracy(INNER_ZONE['radius']) - self.assert_location_state('inner_2') - - def test_event_beacon_unknown_zone_no_location(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. Except - # in this case my Device hasn't had a location message - # yet so it's in an odd state where it has state.state - # None and no GPS coords so set the beacon to. - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My current state is None because I haven't seen a - # location message or a GPS or Region # Beacon event - # message. None is the state the test harness set for - # the Device during test case setup. - self.assert_location_state('None') - - # home is the state of a Device constructed through - # the normal code path on it's first observation with - # the conditions I pass along. - self.assert_mobile_tracker_state('home', 'unknown') - - def test_event_beacon_unknown_zone(self): - """Test the event for unknown zone.""" - # A beacon which does not match a HA zone is the - # definition of a mobile beacon. In this case, "unknown" - # will be turned into device_tracker.beacon_unknown and - # that will be tracked at my current location. First I - # set my location so that my state is 'outer' - - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - message = build_message( - {'desc': "unknown"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - - # My state is still outer and now the unknown beacon - # has joined me at outer. - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer', 'unknown') - - def test_event_beacon_entry_zone_loading_dash(self): - """Test the event for beacon zone landing.""" - # Make sure the leading - is ignored - # Owntracks uses this to switch on hold - - message = build_message( - {'desc': "-inner"}, - REGION_BEACON_ENTER_MESSAGE) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') - - # ------------------------------------------------------------------------ - # Mobile Beacon based event entry / exit testing - - def test_mobile_enter_move_beacon(self): - """Test the movement of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see the 'keys' beacon. I set the location of the - # beacon_keys tracker to my current device location. - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(LOCATION_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - # Location update to outside of defined zones. - # I am now 'not home' and neither are my keys. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - - self.assert_location_state(STATE_NOT_HOME) - self.assert_mobile_tracker_state(STATE_NOT_HOME) - - not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] - self.assert_location_latitude(not_home_lat) - self.assert_mobile_tracker_latitude(not_home_lat) - - def test_mobile_enter_exit_region_beacon(self): - """Test the enter and the exit of a mobile beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # GPS enter message should move beacon - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_state(REGION_GPS_ENTER_MESSAGE['desc']) - - # Exit inner zone to outer zone should move beacon to - # center of outer zone - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_exit_move_beacon(self): - """Test the exit move of a beacon.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - - # I see a new mobile beacon - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Exit mobile beacon, should set location - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - # Move after exit should do nothing - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.assert_mobile_tracker_latitude(OUTER_ZONE['latitude']) - self.assert_mobile_tracker_state('outer') - - def test_mobile_multiple_async_enter_exit(self): - """Test the multiple entering.""" - # Test race condition - for _ in range(0, 20): - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) - - fire_mqtt_message( - self.hass, EVENT_TOPIC, - json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) - - self.hass.block_till_done() - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_mobile_multiple_enter_exit(self): - """Test the multiple entering.""" - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - - assert len(self.context.mobile_beacons_active['greg_phone']) == \ - 0 - - def test_complex_movement(self): - """Test a complex sequence representative of real-world use.""" - # I am in the outer zone. - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # Slightly odd, I leave the location by gps before I lose - # sight of the region beacon. This is also a little odd in - # that my GPS coords are now in the 'outer' zone but I did not - # "enter" that zone when I started up so my location is not - # the center of OUTER_ZONE, but rather just my GPS location. - - # gps out of inner event and location - location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # region beacon leave inner - location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # lose keys mobile beacon - lost_keys_location_message = build_message( - {'lat': location_message['lat'] - FIVE_M, - 'lon': location_message['lon'] - FIVE_M}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, lost_keys_location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_latitude(lost_keys_location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('outer') - - # gps leave outer - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) - self.assert_location_latitude(LOCATION_MESSAGE_NOT_HOME['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - # location move not home - location_message = build_message( - {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, - 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, - LOCATION_MESSAGE_NOT_HOME) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(location_message['lat']) - self.assert_mobile_tracker_latitude(lost_keys_location_message['lat']) - self.assert_location_state('not_home') - self.assert_mobile_tracker_state('outer') - - def test_complex_movement_sticky_keys_beacon(self): - """Test a complex sequence which was previously broken.""" - # I am not_home - self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE) - self.assert_location_state('outer') - - # gps to inner location and event, as actually happens with OwnTracks - location_message = build_message( - {'lat': REGION_GPS_ENTER_MESSAGE['lat'], - 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # see keys mobile beacon and location message as actually happens - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # region beacon enter inner event and location as actually happens - # with OwnTracks - location_message = build_message( - {'lat': location_message['lat'] + FIVE_M, - 'lon': location_message['lon'] + FIVE_M}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # This sequence of moves would cause keys to follow - # greg_phone around even after the OwnTracks sent - # a mobile beacon 'leave' event for the keys. - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # enter inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_latitude(INNER_ZONE['latitude']) - self.assert_location_state('inner') - - # enter keys - self.send_message(EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave keys - self.send_message(LOCATION_TOPIC, location_message) - self.send_message(EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # leave inner region beacon - self.send_message(EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, location_message) - self.assert_location_state('inner') - self.assert_mobile_tracker_state('inner') - - # GPS leave inner region, I'm in the 'outer' region now - # but on GPS coords - leave_location_message = build_message( - {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], - 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, - LOCATION_MESSAGE) - self.send_message(EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) - self.send_message(LOCATION_TOPIC, leave_location_message) - self.assert_location_state('outer') - self.assert_mobile_tracker_state('inner') - self.assert_location_latitude(REGION_GPS_LEAVE_MESSAGE['lat']) - self.assert_mobile_tracker_latitude(INNER_ZONE['latitude']) - - def test_waypoint_import_simple(self): - """Test a simple import of list of waypoints.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[1]) - assert wayp is not None - - def test_waypoint_import_blacklist(self): - """Test import of list of waypoints for blacklisted user.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None - - def test_waypoint_import_no_whitelist(self): - """Test import of list of waypoints with no whitelist set.""" - @asyncio.coroutine - def mock_see(**kwargs): - """Fake see method for owntracks.""" - return - - test_config = { - CONF_PLATFORM: 'owntracks', + hass.loop.run_until_complete(setup_owntracks(hass, { CONF_MAX_GPS_ACCURACY: 200, CONF_WAYPOINT_IMPORT: True, - CONF_MQTT_TOPIC: 'owntracks/#', - } - run_coroutine_threadsafe(owntracks.async_setup_scanner( - self.hass, test_config, mock_see), self.hass.loop).result() - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC_BLOCKED, waypoints_message) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is not None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is not None + CONF_WAYPOINT_WHITELIST: ['jon', 'greg'] + }, store_context)) - def test_waypoint_import_bad_json(self): - """Test importing a bad JSON payload.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message, True) - # Check if it made it into states - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[2]) - assert wayp is None - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[3]) - assert wayp is None + def get_context(): + """Get the current context.""" + return context - def test_waypoint_import_existing(self): - """Test importing a zone that exists.""" - waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - # Get the first waypoint exported - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - # Send an update - waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() - self.send_message(WAYPOINTS_TOPIC, waypoints_message) - new_wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp == new_wayp + yield get_context - def test_single_waypoint_import(self): - """Test single waypoint message.""" - waypoint_message = WAYPOINT_MESSAGE.copy() - self.send_message(WAYPOINT_TOPIC, waypoint_message) - wayp = self.hass.states.get(WAYPOINT_ENTITY_NAMES[0]) - assert wayp is not None + patcher.stop() - def test_not_implemented_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_not_impl_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(LWT_TOPIC, LWT_MESSAGE) - patch_handler.stop() - def test_unsupported_message(self): - """Handle not implemented message type.""" - patch_handler = patch('homeassistant.components.device_tracker.' - 'owntracks.async_handle_unsupported_msg', - return_value=mock_coro(False)) - patch_handler.start() - assert not self.send_message(BAD_TOPIC, BAD_MESSAGE) - patch_handler.stop() +async def send_message(hass, topic, message, corrupt=False): + """Test the sending of a message.""" + str_message = json.dumps(message) + if corrupt: + mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX + else: + mod_message = str_message + async_fire_mqtt_message(hass, topic, mod_message) + await hass.async_block_till_done() + await hass.async_block_till_done() + + +def assert_location_state(hass, location): + """Test the assertion of a location state.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.state == location + + +def assert_location_latitude(hass, latitude): + """Test the assertion of a location latitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('latitude') == latitude + + +def assert_location_longitude(hass, longitude): + """Test the assertion of a location longitude.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('longitude') == longitude + + +def assert_location_accuracy(hass, accuracy): + """Test the assertion of a location accuracy.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('gps_accuracy') == accuracy + + +def assert_location_source_type(hass, source_type): + """Test the assertion of source_type.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get('source_type') == source_type + + +def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker state.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.state == location + + +def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker latitude.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('latitude') == latitude + + +def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE): + """Test the assertion of a mobile beacon tracker accuracy.""" + dev_id = MOBILE_BEACON_FMT.format(beacon) + state = hass.states.get(dev_id) + assert state.attributes.get('gps_accuracy') == accuracy + + +async def test_location_invalid_devid(hass, context): + """Test the update of a location.""" + await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE) + state = hass.states.get('device_tracker.paulus_nexus5x') + assert state.state == 'outer' + + +async def test_location_update(hass, context): + """Test the update of a location.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_location_inaccurate_gps(hass, context): + """Test the location for inaccurate GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +async def test_location_zero_accuracy_gps(hass, context): + """Ignore the location for zero accuracy GPS information.""" + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY) + + # Ignored inaccurate GPS. Location remains at previous. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_longitude(hass, LOCATION_MESSAGE['lon']) + + +# ------------------------------------------------------------------------ +# GPS based event entry / exit testing +async def test_event_gps_entry_exit(hass, context): + """Test the entry event.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # Exit switches back to GPS + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + # Left clean zone state + assert not context().regions_entered[USER] + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Now sending a location update moves me again. + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_gps_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_inaccurate(hass, context): + """Test the event for inaccurate entry.""" + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE) + + # I enter the zone even though the message GPS was inaccurate. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_entry_exit_inaccurate(hass, context): + """Test the event for inaccurate exit.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE) + + # Exit doesn't use inaccurate gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_entry_exit_zero_accuracy(hass, context): + """Test entry/exit events with accuracy zero.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO) + + # Exit doesn't use zero gps + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # But does exit region correctly + assert not context().regions_entered[USER] + + +async def test_event_gps_exit_outside_zone_sets_away(hass, context): + """Test the event for exit zone.""" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Exit message far away GPS location + message = build_message( + {'lon': 90.0, + 'lat': 90.0}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Exit forces zone change to away + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_event_gps_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc']) + assert_location_state(hass, 'outer') + + +async def test_event_gps_entry_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat']) + assert_location_state(hass, 'inner') + + +async def test_event_gps_exit_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # Just treat as location update + message = build_message( + {'desc': "unknown"}, + REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + + +async def test_event_entry_zone_loading_dash(hass, context): + """Test the event for zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + message = build_message( + {'desc': "-inner"}, + REGION_GPS_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +async def test_events_only_on(hass, context): + """Test events_only config suppresses location updates.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = True + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Ignored location update. Location remains at previous. + assert_location_state(hass, STATE_NOT_HOME) + + +async def test_events_only_off(hass, context): + """Test when events_only is False.""" + # Sending a location message that is not home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_location_state(hass, STATE_NOT_HOME) + + context().events_only = False + + # Enter and Leave messages + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER) + assert_location_state(hass, 'outer') + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_state(hass, STATE_NOT_HOME) + + # Sending a location message that is inside outer zone + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Location update processed + assert_location_state(hass, 'outer') + + +async def test_event_source_type_entry_exit(hass, context): + """Test the entry and exit events of source type.""" + # Entering the owntracks circular region named "inner" + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + # source_type should be gps when entering using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_ENTER_MESSAGE)) + + # We should be able to enter a beacon zone even inside a gps zone + assert_location_source_type(hass, 'bluetooth_le') + + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + + # source_type should be gps when leaving using gps. + assert_location_source_type(hass, 'gps') + + # owntracks shouldn't send beacon events with acc = 0 + await send_message(hass, EVENT_TOPIC, build_message( + {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE)) + + assert_location_source_type(hass, 'bluetooth_le') + + +# Region Beacon based event entry / exit testing +async def test_event_region_entry_exit(hass, context): + """Test the entry event.""" + # Seeing a beacon named "inner" + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + + # Enter uses the zone's gps co-ords + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # Updates ignored when in a zone + # note that LOCATION_MESSAGE is actually pretty far + # from INNER_ZONE and has good accuracy. I haven't + # received a transition message though so I'm still + # associated with the inner zone regardless of GPS. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # Exit switches back to GPS but the beacon has no coords + # so I am still located at the center of the inner region + # until I receive a location update. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + # Left clean zone state + assert not context().regions_entered[USER] + + # Now sending a location update moves me again. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) + assert_location_accuracy(hass, LOCATION_MESSAGE['acc']) + + +async def test_event_region_with_spaces(hass, context): + """Test the entry event.""" + message = build_message({'desc': "inner 2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner 2') + + message = build_message({'desc': "inner 2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # Left clean zone state + assert not context().regions_entered[USER] + + +async def test_event_region_entry_exit_right_order(hass, context): + """Test the event for ordering.""" + # Enter inner zone + # Set location to the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # See 'inner' region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # See 'inner_2' region beacon + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'inner' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + # Exit inner - should be in 'outer' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner') + + +async def test_event_region_entry_exit_wrong_order(hass, context): + """Test the event for wrong order.""" + # Enter inner zone + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + assert_location_state(hass, 'inner') + + # Enter inner2 zone + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner_2') + + # Exit inner - should still be in 'inner_2' + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + assert_location_state(hass, 'inner_2') + + # Exit inner_2 - should be in 'outer' + message = build_message( + {'desc': "inner_2"}, + REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # I have not had an actual location update yet and my + # coordinates are set to the center of the last region I + # entered which puts me in the inner_2 zone. + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_accuracy(hass, INNER_ZONE['radius']) + assert_location_state(hass, 'inner_2') + + +async def test_event_beacon_unknown_zone_no_location(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. Except + # in this case my Device hasn't had a location message + # yet so it's in an odd state where it has state.state + # None and no GPS coords so set the beacon to. + hass.states.async_set(DEVICE_TRACKER_STATE, None) + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My current state is None because I haven't seen a + # location message or a GPS or Region # Beacon event + # message. None is the state the test harness set for + # the Device during test case setup. + assert_location_state(hass, 'None') + + # home is the state of a Device constructed through + # the normal code path on it's first observation with + # the conditions I pass along. + assert_mobile_tracker_state(hass, 'home', 'unknown') + + +async def test_event_beacon_unknown_zone(hass, context): + """Test the event for unknown zone.""" + # A beacon which does not match a HA zone is the + # definition of a mobile beacon. In this case, "unknown" + # will be turned into device_tracker.beacon_unknown and + # that will be tracked at my current location. First I + # set my location so that my state is 'outer' + + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + message = build_message( + {'desc': "unknown"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + + # My state is still outer and now the unknown beacon + # has joined me at outer. + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer', 'unknown') + + +async def test_event_beacon_entry_zone_loading_dash(hass, context): + """Test the event for beacon zone landing.""" + # Make sure the leading - is ignored + # Owntracks uses this to switch on hold + + message = build_message( + {'desc': "-inner"}, + REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') + + +# ------------------------------------------------------------------------ +# Mobile Beacon based event entry / exit testing +async def test_mobile_enter_move_beacon(hass, context): + """Test the movement of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see the 'keys' beacon. I set the location of the + # beacon_keys tracker to my current device location. + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + # Location update to outside of defined zones. + # I am now 'not home' and neither are my keys. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + + assert_location_state(hass, STATE_NOT_HOME) + assert_mobile_tracker_state(hass, STATE_NOT_HOME) + + not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat'] + assert_location_latitude(hass, not_home_lat) + assert_mobile_tracker_latitude(hass, not_home_lat) + + +async def test_mobile_enter_exit_region_beacon(hass, context): + """Test the enter and the exit of a mobile beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # GPS enter message should move beacon + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc']) + + # Exit inner zone to outer zone should move beacon to + # center of outer zone + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_exit_move_beacon(hass, context): + """Test the exit move of a beacon.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + + # I see a new mobile beacon + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Exit mobile beacon, should set location + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + # Move after exit should do nothing + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude']) + assert_mobile_tracker_state(hass, 'outer') + + +async def test_mobile_multiple_async_enter_exit(hass, context): + """Test the multiple entering.""" + # Test race condition + for _ in range(0, 20): + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)) + + async_fire_mqtt_message( + hass, EVENT_TOPIC, + json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)) + + await hass.async_block_till_done() + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_mobile_multiple_enter_exit(hass, context): + """Test the multiple entering.""" + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + + assert len(context().mobile_beacons_active['greg_phone']) == \ + 0 + + +async def test_complex_movement(hass, context): + """Test a complex sequence representative of real-world use.""" + # I am in the outer zone. + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # Slightly odd, I leave the location by gps before I lose + # sight of the region beacon. This is also a little odd in + # that my GPS coords are now in the 'outer' zone but I did not + # "enter" that zone when I started up so my location is not + # the center of OUTER_ZONE, but rather just my GPS location. + + # gps out of inner event and location + location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # region beacon leave inner + location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # lose keys mobile beacon + lost_keys_location_message = build_message( + {'lat': location_message['lat'] - FIVE_M, + 'lon': location_message['lon'] - FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, lost_keys_location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_latitude(hass, lost_keys_location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'outer') + + # gps leave outer + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER) + assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + # location move not home + location_message = build_message( + {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M, + 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M}, + LOCATION_MESSAGE_NOT_HOME) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, location_message['lat']) + assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat']) + assert_location_state(hass, 'not_home') + assert_mobile_tracker_state(hass, 'outer') + + +async def test_complex_movement_sticky_keys_beacon(hass, context): + """Test a complex sequence which was previously broken.""" + # I am not_home + await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE) + assert_location_state(hass, 'outer') + + # gps to inner location and event, as actually happens with OwnTracks + location_message = build_message( + {'lat': REGION_GPS_ENTER_MESSAGE['lat'], + 'lon': REGION_GPS_ENTER_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # see keys mobile beacon and location message as actually happens + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # region beacon enter inner event and location as actually happens + # with OwnTracks + location_message = build_message( + {'lat': location_message['lat'] + FIVE_M, + 'lon': location_message['lon'] + FIVE_M}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # This sequence of moves would cause keys to follow + # greg_phone around even after the OwnTracks sent + # a mobile beacon 'leave' event for the keys. + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # enter inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_latitude(hass, INNER_ZONE['latitude']) + assert_location_state(hass, 'inner') + + # enter keys + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave keys + await send_message(hass, LOCATION_TOPIC, location_message) + await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # leave inner region beacon + await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, location_message) + assert_location_state(hass, 'inner') + assert_mobile_tracker_state(hass, 'inner') + + # GPS leave inner region, I'm in the 'outer' region now + # but on GPS coords + leave_location_message = build_message( + {'lat': REGION_GPS_LEAVE_MESSAGE['lat'], + 'lon': REGION_GPS_LEAVE_MESSAGE['lon']}, + LOCATION_MESSAGE) + await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE) + await send_message(hass, LOCATION_TOPIC, leave_location_message) + assert_location_state(hass, 'outer') + assert_mobile_tracker_state(hass, 'inner') + assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat']) + assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude']) + + +async def test_waypoint_import_simple(hass, context): + """Test a simple import of list of waypoints.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1]) + assert wayp is not None + + +async def test_waypoint_import_blacklist(hass, context): + """Test import of list of waypoints for blacklisted user.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_no_whitelist(hass, config_context): + """Test import of list of waypoints with no whitelist set.""" + await setup_owntracks(hass, { + CONF_MAX_GPS_ACCURACY: 200, + CONF_WAYPOINT_IMPORT: True, + CONF_MQTT_TOPIC: 'owntracks/#', + }) + + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is not None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is not None + + +async def test_waypoint_import_bad_json(hass, context): + """Test importing a bad JSON payload.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True) + # Check if it made it into states + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2]) + assert wayp is None + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3]) + assert wayp is None + + +async def test_waypoint_import_existing(hass, context): + """Test importing a zone that exists.""" + waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + # Get the first waypoint exported + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + # Send an update + waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy() + await send_message(hass, WAYPOINTS_TOPIC, waypoints_message) + new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp == new_wayp + + +async def test_single_waypoint_import(hass, context): + """Test single waypoint message.""" + waypoint_message = WAYPOINT_MESSAGE.copy() + await send_message(hass, WAYPOINT_TOPIC, waypoint_message) + wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0]) + assert wayp is not None + + +async def test_not_implemented_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_not_impl_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE) + patch_handler.stop() + + +async def test_unsupported_message(hass, context): + """Handle not implemented message type.""" + patch_handler = patch('homeassistant.components.device_tracker.' + 'owntracks.async_handle_unsupported_msg', + return_value=mock_coro(False)) + patch_handler.start() + assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE) + patch_handler.stop() def generate_ciphers(secret): @@ -1310,162 +1346,138 @@ def mock_cipher(): return len(TEST_SECRET_KEY), mock_decrypt -class TestDeviceTrackerOwnTrackConfigs(BaseMQTT): - """Test the OwnTrack sensor.""" +@pytest.fixture +def config_context(hass, setup_comp): + """Set up the mocked context.""" + patch_load = patch( + 'homeassistant.components.device_tracker.async_load_config', + return_value=mock_coro([])) + patch_load.start() - # pylint: disable=invalid-name + patch_save = patch('homeassistant.components.device_tracker.' + 'DeviceTracker.async_update_config') + patch_save.start() - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - mock_component(self.hass, 'group') - mock_component(self.hass, 'zone') + yield - patch_load = patch( - 'homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])) - patch_load.start() - self.addCleanup(patch_load.stop) + patch_load.stop() + patch_save.stop() - patch_save = patch('homeassistant.components.device_tracker.' - 'DeviceTracker.async_update_config') - patch_save.start() - self.addCleanup(patch_save.stop) - def teardown_method(self, method): - """Tear down resources.""" - self.hass.stop() +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload(hass, config_context): + """Test encrypted payload.""" + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload(self): - """Test encrypted payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_topic_key(self): - """Test encrypted payload with a topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: TEST_SECRET_KEY, - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_topic_key(hass, config_context): + """Test encrypted payload with a topic key.""" + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: TEST_SECRET_KEY, + } + }) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_key(self): - """Test encrypted payload with no key, .""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - # key missing - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_key(self): - """Test encrypted payload with wrong key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: 'wrong key', - }}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_key(hass, config_context): + """Test encrypted payload with no key, .""" + assert hass.states.get(DEVICE_TRACKER_STATE) is None + await setup_owntracks(hass, { + CONF_SECRET: { + } + }) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_wrong_topic_key(self): - """Test encrypted payload with wrong topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - LOCATION_TOPIC: 'wrong key' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None - @patch('homeassistant.components.device_tracker.owntracks.get_cipher', - mock_cipher) - def test_encrypted_payload_no_topic_key(self): - """Test encrypted payload with no topic key.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: { - 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' - }}}) - self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) - assert self.hass.states.get(DEVICE_TRACKER_STATE) is None +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_key(hass, config_context): + """Test encrypted payload with wrong key.""" + await setup_owntracks(hass, { + CONF_SECRET: 'wrong key', + }) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_wrong_topic_key(hass, config_context): + """Test encrypted payload with wrong topic key.""" + await setup_owntracks(hass, { + CONF_SECRET: { + LOCATION_TOPIC: 'wrong key' + }, + }) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +@patch('homeassistant.components.device_tracker.owntracks.get_cipher', + mock_cipher) +async def test_encrypted_payload_no_topic_key(hass, config_context): + """Test encrypted payload with no topic key.""" + await setup_owntracks(hass, { + CONF_SECRET: { + 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar' + }}) + await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE) + assert hass.states.get(DEVICE_TRACKER_STATE) is None + + +async def test_encrypted_payload_libsodium(hass, config_context): + """Test sending encrypted message payload.""" try: - import libnacl + import libnacl # noqa: F401 except (ImportError, OSError): - libnacl = None + pytest.skip("libnacl/libsodium is not installed") + return - @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed") - def test_encrypted_payload_libsodium(self): - """Test sending encrypted message payload.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_SECRET: TEST_SECRET_KEY, - }}) + await setup_owntracks(hass, { + CONF_SECRET: TEST_SECRET_KEY, + }) - self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - def test_customized_mqtt_topic(self): - """Test subscribing to a custom mqtt topic.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_MQTT_TOPIC: 'mytracks/#', - }}) - topic = 'mytracks/{}/{}'.format(USER, DEVICE) +async def test_customized_mqtt_topic(hass, config_context): + """Test subscribing to a custom mqtt topic.""" + await setup_owntracks(hass, { + CONF_MQTT_TOPIC: 'mytracks/#', + }) - self.send_message(topic, LOCATION_MESSAGE) - self.assert_location_latitude(LOCATION_MESSAGE['lat']) + topic = 'mytracks/{}/{}'.format(USER, DEVICE) - def test_region_mapping(self): - """Test region to zone mapping.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'owntracks', - CONF_REGION_MAPPING: { - 'foo': 'inner' - }, - }}) + await send_message(hass, topic, LOCATION_MESSAGE) + assert_location_latitude(hass, LOCATION_MESSAGE['lat']) - self.hass.states.set( - 'zone.inner', 'zoning', INNER_ZONE) - message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) - assert message['desc'] == 'foo' +async def test_region_mapping(hass, config_context): + """Test region to zone mapping.""" + await setup_owntracks(hass, { + CONF_REGION_MAPPING: { + 'foo': 'inner' + }, + }) - self.send_message(EVENT_TOPIC, message) - self.assert_location_state('inner') + hass.states.async_set( + 'zone.inner', 'zoning', INNER_ZONE) + + message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE) + assert message['desc'] == 'foo' + + await send_message(hass, EVENT_TOPIC, message) + assert_location_state(hass, 'inner') diff --git a/tests/components/device_tracker/test_owntracks_http.py b/tests/components/device_tracker/test_owntracks_http.py deleted file mode 100644 index d7b48cafe46..00000000000 --- a/tests/components/device_tracker/test_owntracks_http.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Test the owntracks_http platform.""" -import asyncio -from unittest.mock import patch - -import pytest - -from homeassistant.setup import async_setup_component - -from tests.common import mock_coro, mock_component - - -@pytest.fixture -def mock_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" - mock_component(hass, 'group') - mock_component(hass, 'zone') - with patch('homeassistant.components.device_tracker.async_load_config', - return_value=mock_coro([])): - hass.loop.run_until_complete( - async_setup_component(hass, 'device_tracker', { - 'device_tracker': { - 'platform': 'owntracks_http' - } - })) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@pytest.fixture -def mock_handle_message(): - """Mock async_handle_message.""" - with patch('homeassistant.components.device_tracker.' - 'owntracks_http.async_handle_message') as mock: - mock.return_value = mock_coro(None) - yield mock - - -@asyncio.coroutine -def test_forward_message_correctly(mock_client, mock_handle_message): - """Test that we forward messages correctly to OwnTracks handle message.""" - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) - assert resp.status == 200 - assert len(mock_handle_message.mock_calls) == 1 - - data = mock_handle_message.mock_calls[0][1][2] - assert data == { - '_type': 'test', - 'topic': 'owntracks/user/device' - } - - -@asyncio.coroutine -def test_handle_value_error(mock_client, mock_handle_message): - """Test that we handle errors from handle message correctly.""" - mock_handle_message.side_effect = ValueError - resp = yield from mock_client.post('/api/owntracks/user/device', json={ - '_type': 'test' - }) - assert resp.status == 500 diff --git a/tests/components/geofency/__init__.py b/tests/components/geofency/__init__.py new file mode 100644 index 00000000000..12313e062db --- /dev/null +++ b/tests/components/geofency/__init__.py @@ -0,0 +1 @@ +"""Tests for the Geofency component.""" diff --git a/tests/components/device_tracker/test_geofency.py b/tests/components/geofency/test_init.py similarity index 82% rename from tests/components/device_tracker/test_geofency.py rename to tests/components/geofency/test_init.py index d84940d9fbf..442660c2daf 100644 --- a/tests/components/device_tracker/test_geofency.py +++ b/tests/components/geofency/test_init.py @@ -1,16 +1,14 @@ """The tests for the Geofency device tracker platform.""" # pylint: disable=redefined-outer-name -import asyncio from unittest.mock import patch import pytest from homeassistant.components import zone -import homeassistant.components.device_tracker as device_tracker -from homeassistant.components.device_tracker.geofency import ( - CONF_MOBILE_BEACONS, URL) +from homeassistant.components.geofency import ( + CONF_MOBILE_BEACONS, URL, DOMAIN) from homeassistant.const import ( - CONF_PLATFORM, HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, + HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) from homeassistant.setup import async_setup_component from homeassistant.util import slugify @@ -110,9 +108,8 @@ BEACON_EXIT_CAR = { def geofency_client(loop, hass, aiohttp_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( - hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'geofency', + hass, DOMAIN, { + DOMAIN: { CONF_MOBILE_BEACONS: ['Car 1'] }})) @@ -133,11 +130,10 @@ def setup_zones(loop, hass): }})) -@asyncio.coroutine -def test_data_validation(geofency_client): +async def test_data_validation(geofency_client): """Test data validation.""" # No data - req = yield from geofency_client.post(URL) + req = await geofency_client.post(URL) assert req.status == HTTP_UNPROCESSABLE_ENTITY missing_attributes = ['address', 'device', @@ -147,15 +143,15 @@ def test_data_validation(geofency_client): for attribute in missing_attributes: copy = GPS_ENTER_HOME.copy() del copy[attribute] - req = yield from geofency_client.post(URL, data=copy) + req = await geofency_client.post(URL, data=copy) assert req.status == HTTP_UNPROCESSABLE_ENTITY -@asyncio.coroutine -def test_gps_enter_and_exit_home(hass, geofency_client): +async def test_gps_enter_and_exit_home(hass, geofency_client): """Test GPS based zone enter and exit.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=GPS_ENTER_HOME) + req = await geofency_client.post(URL, data=GPS_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -163,7 +159,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=GPS_EXIT_HOME) + req = await geofency_client.post(URL, data=GPS_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) state_name = hass.states.get('{}.{}'.format( @@ -175,7 +172,8 @@ def test_gps_enter_and_exit_home(hass, geofency_client): data['currentLatitude'] = NOT_HOME_LATITUDE data['currentLongitude'] = NOT_HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME['device']) current_latitude = hass.states.get('{}.{}'.format( @@ -186,11 +184,11 @@ def test_gps_enter_and_exit_home(hass, geofency_client): assert NOT_HOME_LONGITUDE == current_longitude -@asyncio.coroutine -def test_beacon_enter_and_exit_home(hass, geofency_client): +async def test_beacon_enter_and_exit_home(hass, geofency_client): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" # Enter the Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_HOME) + req = await geofency_client.post(URL, data=BEACON_ENTER_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -198,7 +196,8 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_HOME == state_name # Exit the Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_HOME) + req = await geofency_client.post(URL, data=BEACON_EXIT_HOME) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name'])) state_name = hass.states.get('{}.{}'.format( @@ -206,11 +205,11 @@ def test_beacon_enter_and_exit_home(hass, geofency_client): assert STATE_NOT_HOME == state_name -@asyncio.coroutine -def test_beacon_enter_and_exit_car(hass, geofency_client): +async def test_beacon_enter_and_exit_car(hass, geofency_client): """Test use of mobile iBeacon.""" # Enter the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_ENTER_CAR) + req = await geofency_client.post(URL, data=BEACON_ENTER_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -218,7 +217,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone - req = yield from geofency_client.post(URL, data=BEACON_EXIT_CAR) + req = await geofency_client.post(URL, data=BEACON_EXIT_CAR) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name'])) state_name = hass.states.get('{}.{}'.format( @@ -229,7 +229,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): data = BEACON_ENTER_CAR.copy() data['latitude'] = HOME_LATITUDE data['longitude'] = HOME_LONGITUDE - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format( @@ -237,7 +238,8 @@ def test_beacon_enter_and_exit_car(hass, geofency_client): assert STATE_HOME == state_name # Exit the Car in the Home zone - req = yield from geofency_client.post(URL, data=data) + req = await geofency_client.post(URL, data=data) + await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify("beacon_{}".format(data['name'])) state_name = hass.states.get('{}.{}'.format( diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 273a7e86505..1568919a9b4 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -183,7 +183,10 @@ DEMO_DEVICES = [{ 'name': { 'name': 'Living Room Fan' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': [ + 'action.devices.traits.FanSpeed', + 'action.devices.traits.OnOff' + ], 'type': 'action.devices.types.FAN', 'willReportState': False }, { @@ -191,7 +194,10 @@ DEMO_DEVICES = [{ 'name': { 'name': 'Ceiling Fan' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': [ + 'action.devices.traits.FanSpeed', + 'action.devices.traits.OnOff' + ], 'type': 'action.devices.types.FAN', 'willReportState': False }, { @@ -230,4 +236,28 @@ DEMO_DEVICES = [{ 'traits': ['action.devices.traits.TemperatureSetting'], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False +}, { + 'id': 'lock.front_door', + 'name': { + 'name': 'Front Door' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False +}, { + 'id': 'lock.kitchen_door', + 'name': { + 'name': 'Kitchen Door' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False +}, { + 'id': 'lock.openable_lock', + 'name': { + 'name': 'Openable Lock' + }, + 'traits': ['action.devices.traits.LockUnlock'], + 'type': 'action.devices.types.LOCK', + 'willReportState': False }] diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 2ebfa5cc9ed..047fad3574c 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -8,7 +8,8 @@ import pytest from homeassistant import core, const, setup from homeassistant.components import ( - fan, cover, light, switch, climate, async_setup, media_player) + fan, cover, light, switch, climate, lock, async_setup, media_player) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.components import google_assistant as ga from . import DEMO_DEVICES @@ -96,6 +97,13 @@ def hass_fixture(loop, hass): }] })) + loop.run_until_complete( + setup.async_setup_component(hass, lock.DOMAIN, { + 'lock': [{ + 'platform': 'demo' + }] + })) + return hass @@ -116,6 +124,9 @@ def test_sync_request(hass_fixture, assistant_client, auth_header): sorted([dev['id'] for dev in devices]) == sorted([dev['id'] for dev in DEMO_DEVICES])) + for dev in devices: + assert dev['id'] not in CLOUD_NEVER_EXPOSED_ENTITIES + for dev, demo in zip( sorted(devices, key=lambda d: d['id']), sorted(DEMO_DEVICES, key=lambda d: d['id'])): diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index a347b6c6fc0..42af1230eed 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -1,16 +1,13 @@ """Tests for the Google Assistant traits.""" import pytest -from homeassistant.const import ( - STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES) -from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.components import ( climate, cover, fan, input_boolean, light, + lock, media_player, scene, script, @@ -19,10 +16,24 @@ from homeassistant.components import ( group, ) 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) +from homeassistant.core import State, DOMAIN as HA_DOMAIN from homeassistant.util import color - from tests.common import async_mock_service +BASIC_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', +) + +UNSAFE_CONFIG = helpers.Config( + should_expose=lambda state: True, + agent_user_id='test-agent', + allow_unlock=True, +) + async def test_brightness_light(hass): """Test brightness trait support for light domain.""" @@ -31,7 +42,7 @@ async def test_brightness_light(hass): trt = trait.BrightnessTrait(hass, State('light.bla', light.STATE_ON, { light.ATTR_BRIGHTNESS: 243 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -57,7 +68,7 @@ async def test_brightness_cover(hass): trt = trait.BrightnessTrait(hass, State('cover.bla', cover.STATE_OPEN, { cover.ATTR_CURRENT_POSITION: 75 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -85,7 +96,7 @@ async def test_brightness_media_player(hass): trt = trait.BrightnessTrait(hass, State( 'media_player.bla', media_player.STATE_PLAYING, { media_player.ATTR_MEDIA_VOLUME_LEVEL: .3 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -109,7 +120,7 @@ async def test_onoff_group(hass): """Test OnOff trait support for group domain.""" assert trait.OnOffTrait.supported(group.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -117,7 +128,9 @@ async def test_onoff_group(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -145,7 +158,8 @@ async def test_onoff_input_boolean(hass): """Test OnOff trait support for input_boolean domain.""" assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -153,7 +167,9 @@ async def test_onoff_input_boolean(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -182,7 +198,8 @@ async def test_onoff_switch(hass): """Test OnOff trait support for switch domain.""" assert trait.OnOffTrait.supported(switch.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -190,7 +207,9 @@ async def test_onoff_switch(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -218,7 +237,7 @@ async def test_onoff_fan(hass): """Test OnOff trait support for fan domain.""" assert trait.OnOffTrait.supported(fan.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -226,7 +245,7 @@ async def test_onoff_fan(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF), BASIC_CONFIG) assert trt_off.query_attributes() == { 'on': False } @@ -254,7 +273,7 @@ async def test_onoff_light(hass): """Test OnOff trait support for light domain.""" assert trait.OnOffTrait.supported(light.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -262,7 +281,9 @@ async def test_onoff_light(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -290,7 +311,8 @@ async def test_onoff_cover(hass): """Test OnOff trait support for cover domain.""" assert trait.OnOffTrait.supported(cover.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN)) + trt_on = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_OPEN), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -298,7 +320,9 @@ async def test_onoff_cover(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED)) + trt_off = trait.OnOffTrait(hass, State('cover.bla', cover.STATE_CLOSED), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -327,7 +351,8 @@ async def test_onoff_media_player(hass): """Test OnOff trait support for media_player domain.""" assert trait.OnOffTrait.supported(media_player.DOMAIN, 0) - trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON)) + trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON), + BASIC_CONFIG) assert trt_on.sync_attributes() == {} @@ -335,7 +360,9 @@ async def test_onoff_media_player(hass): 'on': True } - trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF)) + trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF), + BASIC_CONFIG) + assert trt_off.query_attributes() == { 'on': False } @@ -349,7 +376,9 @@ async def test_onoff_media_player(hass): ATTR_ENTITY_ID: 'media_player.bla', } - off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) + off_calls = async_mock_service(hass, media_player.DOMAIN, + SERVICE_TURN_OFF) + await trt_on.execute(trait.COMMAND_ONOFF, { 'on': False }) @@ -363,7 +392,8 @@ async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" assert trait.DockTrait.supported(vacuum.DOMAIN, 0) - trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE)) + trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE), + BASIC_CONFIG) assert trt.sync_attributes() == {} @@ -386,7 +416,7 @@ async def test_startstop_vacuum(hass): trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, { ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE, - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == {'pausable': True} @@ -436,7 +466,7 @@ async def test_color_spectrum_light(hass): trt = trait.ColorSpectrumTrait(hass, State('light.bla', STATE_ON, { light.ATTR_HS_COLOR: (0, 94), - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'colorModel': 'rgb' @@ -482,7 +512,7 @@ async def test_color_temperature_light(hass): light.ATTR_MIN_MIREDS: 200, light.ATTR_COLOR_TEMP: 300, light.ATTR_MAX_MIREDS: 500, - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'temperatureMinK': 2000, @@ -538,7 +568,7 @@ async def test_color_temperature_light_bad_temp(hass): light.ATTR_MIN_MIREDS: 200, light.ATTR_COLOR_TEMP: 0, light.ATTR_MAX_MIREDS: 500, - })) + }), BASIC_CONFIG) assert trt.query_attributes() == { } @@ -548,7 +578,7 @@ async def test_scene_scene(hass): """Test Scene trait support for scene domain.""" assert trait.SceneTrait.supported(scene.DOMAIN, 0) - trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE)) + trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) @@ -565,7 +595,7 @@ async def test_scene_script(hass): """Test Scene trait support for script domain.""" assert trait.SceneTrait.supported(script.DOMAIN, 0) - trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF)) + trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG) assert trt.sync_attributes() == {} assert trt.query_attributes() == {} assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) @@ -605,7 +635,7 @@ async def test_temperature_setting_climate_range(hass): climate.ATTR_TARGET_TEMP_LOW: 65, climate.ATTR_MIN_TEMP: 50, climate.ATTR_MAX_TEMP: 80 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'availableThermostatModes': 'off,cool,heat,heatcool', 'thermostatTemperatureUnit': 'F', @@ -672,7 +702,7 @@ async def test_temperature_setting_climate_setpoint(hass): climate.ATTR_MAX_TEMP: 30, climate.ATTR_TEMPERATURE: 18, climate.ATTR_CURRENT_TEMPERATURE: 20 - })) + }), BASIC_CONFIG) assert trt.sync_attributes() == { 'availableThermostatModes': 'off,cool', 'thermostatTemperatureUnit': 'C', @@ -702,3 +732,146 @@ async def test_temperature_setting_climate_setpoint(hass): ATTR_ENTITY_ID: 'climate.bla', climate.ATTR_TEMPERATURE: 19 } + + +async def test_lock_unlock_lock(hass): + """Test LockUnlock trait locking support for lock domain.""" + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_UNLOCKED), + BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': False + } + + 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}) + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'lock.front_door' + } + + +async def test_lock_unlock_unlock(hass): + """Test LockUnlock trait unlocking support for lock domain.""" + assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_LOCKED), + BASIC_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': True + } + + assert not trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + + trt = trait.LockUnlockTrait(hass, + State('lock.front_door', lock.STATE_LOCKED), + UNSAFE_CONFIG) + + assert trt.sync_attributes() == {} + + assert trt.query_attributes() == { + 'isLocked': True + } + + 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}) + + assert len(calls) == 1 + assert calls[0].data == { + ATTR_ENTITY_ID: 'lock.front_door' + } + + +async def test_fan_speed(hass): + """Test FanSpeed trait speed control support for fan domain.""" + assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED) + + trt = trait.FanSpeedTrait( + hass, State( + 'fan.living_room_fan', fan.SPEED_HIGH, attributes={ + 'speed_list': [ + fan.SPEED_OFF, fan.SPEED_LOW, fan.SPEED_MEDIUM, + fan.SPEED_HIGH + ], + 'speed': 'low' + }), BASIC_CONFIG) + + assert trt.sync_attributes() == { + 'availableFanSpeeds': { + 'ordered': True, + 'speeds': [ + { + 'speed_name': 'off', + 'speed_values': [ + { + 'speed_synonym': ['stop', 'off'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'low', + 'speed_values': [ + { + 'speed_synonym': [ + 'slow', 'low', 'slowest', 'lowest'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'medium', + 'speed_values': [ + { + 'speed_synonym': ['medium', 'mid', 'middle'], + 'lang': 'en' + } + ] + }, + { + 'speed_name': 'high', + 'speed_values': [ + { + 'speed_synonym': [ + 'high', 'max', 'fast', 'highest', 'fastest', + 'maximum'], + 'lang': 'en' + } + ] + } + ] + }, + 'reversible': False + } + + assert trt.query_attributes() == { + 'currentFanSpeedSetting': 'low', + 'on': True, + 'online': True + } + + assert trt.can_execute( + 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'}) + + assert len(calls) == 1 + assert calls[0].data == { + 'entity_id': 'fan.living_room_fan', + 'speed': 'medium' + } diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 4fd59dd3f7a..51fca931faa 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, Mock 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) @@ -106,6 +107,8 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, ) assert hassio_user is not None assert hassio_user.system_generated + assert len(hassio_user.groups) == 1 + assert hassio_user.groups[0].id == GROUP_ID_ADMIN for token in hassio_user.refresh_tokens.values(): if token.token == refresh_token: break @@ -113,6 +116,31 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, assert False, 'refresh token not found' +async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + # Create user without admin + user = await hass.auth.async_create_system_user('Hass.io') + assert not user.is_admin + await hass.auth.async_create_refresh_token(user) + + hass_storage[STORAGE_KEY] = { + 'data': {'hassio_user': user.id}, + 'key': STORAGE_KEY, + 'version': 1 + } + + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert user.is_admin + + async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 7d303c38e93..d39609b079a 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -139,6 +139,7 @@ def test_type_sensors(type_name, entity_id, state, attrs): ('Switch', 'automation.test', 'on', {}, {}), ('Switch', 'input_boolean.test', 'on', {}, {}), ('Switch', 'remote.test', 'on', {}, {}), + ('Switch', 'scene.test', 'on', {}, {}), ('Switch', 'script.test', 'on', {}, {}), ('Switch', 'switch.test', 'on', {}, {}), ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}), diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index a831a7e9e5d..4dbb6351ee7 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -9,8 +9,8 @@ from homeassistant.components.homekit import ( STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT) from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( - CONF_AUTO_START, BRIDGE_NAME, DEFAULT_PORT, DOMAIN, HOMEKIT_FILE, - SERVICE_HOMEKIT_START) + CONF_AUTO_START, CONF_SAFE_MODE, BRIDGE_NAME, DEFAULT_PORT, + DEFAULT_SAFE_MODE, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START) from homeassistant.const import ( CONF_NAME, CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) @@ -49,7 +49,7 @@ async def test_setup_min(hass): hass, DOMAIN, {DOMAIN: {}}) mock_homekit.assert_any_call(hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY, - {}) + {}, DEFAULT_SAFE_MODE) assert mock_homekit().setup.called is True # Test auto start enabled @@ -63,7 +63,8 @@ async def test_setup_min(hass): async def test_setup_auto_start_disabled(hass): """Test async_setup with auto start disabled and test service calls.""" config = {DOMAIN: {CONF_AUTO_START: False, CONF_NAME: 'Test Name', - CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0'}} + CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0', + CONF_SAFE_MODE: DEFAULT_SAFE_MODE}} with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: mock_homekit.return_value = homekit = Mock() @@ -71,7 +72,7 @@ async def test_setup_auto_start_disabled(hass): hass, DOMAIN, config) mock_homekit.assert_any_call(hass, 'Test Name', 11111, '172.0.0.0', ANY, - {}) + {}, DEFAULT_SAFE_MODE) assert mock_homekit().setup.called is True # Test auto_start disabled @@ -99,7 +100,8 @@ async def test_setup_auto_start_disabled(hass): async def test_homekit_setup(hass, hk_driver): """Test setup of bridge and driver.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}) + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, + DEFAULT_SAFE_MODE) with patch(PATH_HOMEKIT + '.accessories.HomeDriver', return_value=hk_driver) as mock_driver, \ @@ -111,6 +113,7 @@ async def test_homekit_setup(hass, hk_driver): assert isinstance(homekit.bridge, HomeBridge) mock_driver.assert_called_with( hass, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path) + assert homekit.driver.safe_mode is False # Test if stop listener is setup assert hass.bus.async_listeners().get(EVENT_HOMEASSISTANT_STOP) == 1 @@ -118,7 +121,8 @@ async def test_homekit_setup(hass, hk_driver): async def test_homekit_setup_ip_address(hass, hk_driver): """Test setup with given IP address.""" - homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {}) + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {}, + None) with patch(PATH_HOMEKIT + '.accessories.HomeDriver', return_value=hk_driver) as mock_driver: @@ -127,9 +131,20 @@ async def test_homekit_setup_ip_address(hass, hk_driver): hass, address='172.0.0.0', port=DEFAULT_PORT, persist_file=ANY) +async def test_homekit_setup_safe_mode(hass, hk_driver): + """Test if safe_mode flag is set.""" + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, True) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver): + await hass.async_add_job(homekit.setup) + assert homekit.driver.safe_mode is True + + async def test_homekit_add_accessory(): """Add accessory if config exists and get_acc returns an accessory.""" - homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {}) + homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {}, + None) homekit.driver = 'driver' homekit.bridge = mock_bridge = Mock() @@ -152,7 +167,7 @@ async def test_homekit_add_accessory(): async def test_homekit_entity_filter(hass): """Test the entity filter.""" entity_filter = generate_filter(['cover'], ['demo.test'], [], []) - homekit = HomeKit(hass, None, None, None, entity_filter, {}) + homekit = HomeKit(hass, None, None, None, entity_filter, {}, None) with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: mock_get_acc.return_value = None @@ -172,7 +187,7 @@ async def test_homekit_entity_filter(hass): async def test_homekit_start(hass, hk_driver, debounce_patcher): """Test HomeKit start method.""" pin = b'123-45-678' - homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}) + homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}, None) homekit.bridge = Mock() homekit.bridge.accessories = [] homekit.driver = hk_driver @@ -203,7 +218,7 @@ async def test_homekit_start(hass, hk_driver, debounce_patcher): async def test_homekit_stop(hass): """Test HomeKit stop method.""" - homekit = HomeKit(hass, None, None, None, None, None) + homekit = HomeKit(hass, None, None, None, None, None, None) homekit.driver = Mock() assert homekit.status == STATUS_READY @@ -222,7 +237,7 @@ async def test_homekit_stop(hass): async def test_homekit_too_many_accessories(hass, hk_driver): """Test adding too many accessories to HomeKit.""" - homekit = HomeKit(hass, None, None, None, None, None) + homekit = HomeKit(hass, None, None, None, None, None, None) homekit.bridge = Mock() homekit.bridge.accessories = range(MAX_DEVICES + 1) homekit.driver = hk_driver diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index c32abaef0dd..a39af399dce 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -80,13 +80,30 @@ async def test_garage_door_open_close(hass, hk_driver, cls, events): hass.states.async_set(entity_id, STATE_CLOSED) await hass.async_block_till_done() + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] is None + await hass.async_add_job(acc.char_target_state.client_update_value, 0) await hass.async_block_till_done() assert call_open_cover assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - assert len(events) == 2 + assert len(events) == 3 + assert events[-1].data[ATTR_VALUE] is None + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert len(events) == 4 assert events[-1].data[ATTR_VALUE] is None diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index d170647d492..204cc90207c 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -1,14 +1,18 @@ """Test different accessory types: Switches.""" +from datetime import timedelta + import pytest from homeassistant.components.homekit.const import ( ATTR_VALUE, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE) from homeassistant.components.homekit.type_switches import ( Outlet, Switch, Valve) +from homeassistant.components.script import ATTR_CAN_CANCEL from homeassistant.const import ATTR_ENTITY_ID, CONF_TYPE, STATE_OFF, STATE_ON from homeassistant.core import split_entity_id +import homeassistant.util.dt as dt_util -from tests.common import async_mock_service +from tests.common import async_fire_time_changed, async_mock_service async def test_outlet_set_state(hass, hk_driver, events): @@ -54,18 +58,18 @@ async def test_outlet_set_state(hass, hk_driver, events): assert events[-1].data[ATTR_VALUE] is None -@pytest.mark.parametrize('entity_id', [ - 'automation.test', - 'input_boolean.test', - 'remote.test', - 'script.test', - 'switch.test', +@pytest.mark.parametrize('entity_id, attrs', [ + ('automation.test', {}), + ('input_boolean.test', {}), + ('remote.test', {}), + ('script.test', {ATTR_CAN_CANCEL: True}), + ('switch.test', {}), ]) -async def test_switch_set_state(hass, hk_driver, entity_id, events): +async def test_switch_set_state(hass, hk_driver, entity_id, attrs, events): """Test if accessory and HA are updated accordingly.""" domain = split_entity_id(entity_id)[0] - hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id, None, attrs) await hass.async_block_till_done() acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) await hass.async_add_job(acc.run) @@ -74,13 +78,14 @@ async def test_switch_set_state(hass, hk_driver, entity_id, events): assert acc.aid == 2 assert acc.category == 8 # Switch + assert acc.activate_only is False assert acc.char_on.value is False - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, attrs) await hass.async_block_till_done() assert acc.char_on.value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, attrs) await hass.async_block_till_done() assert acc.char_on.value is False @@ -172,3 +177,66 @@ async def test_valve_set_state(hass, hk_driver, events): assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id assert len(events) == 2 assert events[-1].data[ATTR_VALUE] is None + + +@pytest.mark.parametrize('entity_id, attrs', [ + ('scene.test', {}), + ('script.test', {}), + ('script.test', {ATTR_CAN_CANCEL: False}), +]) +async def test_reset_switch(hass, hk_driver, entity_id, attrs, events): + """Test if switch accessory is reset correctly.""" + domain = split_entity_id(entity_id)[0] + + hass.states.async_set(entity_id, None, attrs) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.activate_only is True + assert acc.char_on.value is False + + call_turn_on = async_mock_service(hass, domain, 'turn_on') + call_turn_off = async_mock_service(hass, domain, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert acc.char_on.value is True + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] is None + + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 + assert not call_turn_off + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert acc.char_on.value is False + assert len(events) == 1 + + +async def test_reset_switch_reload(hass, hk_driver, events): + """Test reset switch after script reload.""" + entity_id = 'script.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.activate_only is True + + hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: True}) + await hass.async_block_till_done() + assert acc.activate_only is False + + hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: False}) + await hass.async_block_till_done() + assert acc.activate_only is True diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index 795cb5db7d2..f645cddf730 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -11,7 +11,7 @@ from homeassistant.components.climate import ( DOMAIN as DOMAIN_CLIMATE, STATE_AUTO, STATE_COOL, STATE_HEAT) from homeassistant.components.homekit.const import ( ATTR_VALUE, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER, - PROP_MAX_VALUE, PROP_MIN_VALUE) + PROP_MAX_VALUE, PROP_MIN_STEP, PROP_MIN_VALUE) from homeassistant.components.water_heater import ( DOMAIN as DOMAIN_WATER_HEATER) from homeassistant.const import ( @@ -48,6 +48,7 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.aid == 2 assert acc.category == 9 # Thermostat + assert acc.get_temperature_range() == (7.0, 35.0) assert acc.char_current_heat_cool.value == 0 assert acc.char_target_heat_cool.value == 0 assert acc.char_current_temp.value == 21.0 @@ -58,11 +59,12 @@ async def test_thermostat(hass, hk_driver, cls, events): assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_HEAT, {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0}) + ATTR_TEMPERATURE: 22.2, + ATTR_CURRENT_TEMPERATURE: 17.8}) await hass.async_block_till_done() assert acc.char_target_temp.value == 22.0 assert acc.char_current_heat_cool.value == 1 @@ -193,10 +195,12 @@ async def test_thermostat_auto(hass, hk_driver, cls, events): == DEFAULT_MAX_TEMP assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] \ == DEFAULT_MIN_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.5 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] \ == DEFAULT_MAX_TEMP assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] \ == DEFAULT_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_AUTO, {ATTR_OPERATION_MODE: STATE_AUTO, @@ -339,10 +343,11 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): hass.states.async_set(entity_id, STATE_AUTO, {ATTR_OPERATION_MODE: STATE_AUTO, ATTR_TARGET_TEMP_HIGH: 75.2, - ATTR_TARGET_TEMP_LOW: 68, + ATTR_TARGET_TEMP_LOW: 68.1, ATTR_TEMPERATURE: 71.6, ATTR_CURRENT_TEMPERATURE: 73.4}) await hass.async_block_till_done() + assert acc.get_temperature_range() == (7.0, 35.0) assert acc.char_heating_thresh_temp.value == 20.0 assert acc.char_cooling_thresh_temp.value == 24.0 assert acc.char_current_temp.value == 23.0 @@ -358,28 +363,28 @@ async def test_thermostat_fahrenheit(hass, hk_driver, cls, events): await hass.async_block_till_done() assert call_set_temperature[0] assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5 assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.4°F' + assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.5°F' await hass.async_add_job( acc.char_heating_thresh_temp.client_update_value, 22) await hass.async_block_till_done() assert call_set_temperature[1] assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 - assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.6°F' + assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.5°F' await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0) await hass.async_block_till_done() assert call_set_temperature[2] assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id - assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0 assert len(events) == 3 - assert events[-1].data[ATTR_VALUE] == '75.2°F' + assert events[-1].data[ATTR_VALUE] == '75.0°F' async def test_thermostat_get_temperature_range(hass, hk_driver, cls): @@ -399,7 +404,7 @@ async def test_thermostat_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.6, 21.1) + assert acc.get_temperature_range() == (15.5, 21.0) async def test_water_heater(hass, hk_driver, cls, events): @@ -425,6 +430,7 @@ async def test_water_heater(hass, hk_driver, cls, events): DEFAULT_MAX_TEMP_WATER_HEATER assert acc.char_target_temp.properties[PROP_MIN_VALUE] == \ DEFAULT_MIN_TEMP_WATER_HEATER + assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5 hass.states.async_set(entity_id, STATE_HEAT, {ATTR_OPERATION_MODE: STATE_HEAT, @@ -508,7 +514,7 @@ async def test_water_heater_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_HEAT) await hass.async_block_till_done() - acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + acc = cls.thermostat(hass, hk_driver, 'WaterHeater', entity_id, 2, None) hass.states.async_set(entity_id, STATE_HEAT, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25}) @@ -519,4 +525,4 @@ async def test_water_heater_get_temperature_range(hass, hk_driver, cls): hass.states.async_set(entity_id, STATE_OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.6, 21.1) + assert acc.get_temperature_range() == (15.5, 21.0) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0368dfa642e..a2849a77396 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -99,13 +99,13 @@ def test_convert_to_float(): def test_temperature_to_homekit(): """Test temperature conversion from HA to HomeKit.""" assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5 - assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.4 + assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.5 def test_temperature_to_states(): """Test temperature conversion from HomeKit to HA.""" assert temperature_to_states(20, TEMP_CELSIUS) == 20.0 - assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.4 + assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.5 def test_density_to_air_quality(): diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index a04fb853996..09474a5ad06 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -476,7 +476,7 @@ async def test_intent_set_color_and_brightness(hass): assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 -async def test_light_context(hass): +async def test_light_context(hass, hass_admin_user): """Test that light context works.""" assert await async_setup_component(hass, 'light', { 'light': { @@ -489,9 +489,9 @@ async def test_light_context(hass): await hass.services.async_call('light', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('light.ceiling') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/light/test_switch.py b/tests/components/light/test_switch.py new file mode 100644 index 00000000000..5e6bebb56ef --- /dev/null +++ b/tests/components/light/test_switch.py @@ -0,0 +1,81 @@ +"""The tests for the Light Switch platform.""" + +from homeassistant.setup import async_setup_component +from tests.components.light import common +from tests.components.switch import common as switch_common + + +async def test_default_state(hass): + """Test light switch default state.""" + await async_setup_component(hass, 'light', {'light': { + 'platform': 'switch', 'entity_id': 'switch.test', + 'name': 'Christmas Tree Lights' + }}) + await hass.async_block_till_done() + + state = hass.states.get('light.christmas_tree_lights') + assert state is not None + assert state.state == 'unavailable' + assert state.attributes['supported_features'] == 0 + assert state.attributes.get('brightness') is None + assert state.attributes.get('hs_color') is None + assert state.attributes.get('color_temp') is None + assert state.attributes.get('white_value') is None + assert state.attributes.get('effect_list') is None + assert state.attributes.get('effect') is None + + +async def test_light_service_calls(hass): + """Test service calls to light.""" + await async_setup_component(hass, 'switch', {'switch': [ + {'platform': 'demo'} + ]}) + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'switch', 'entity_id': 'switch.decorative_lights'} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.light_switch').state == 'on' + + common.async_toggle(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + common.async_turn_on(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'on' + assert hass.states.get('light.light_switch').state == 'on' + + common.async_turn_off(hass, 'light.light_switch') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + +async def test_switch_service_calls(hass): + """Test service calls to switch.""" + await async_setup_component(hass, 'switch', {'switch': [ + {'platform': 'demo'} + ]}) + await async_setup_component(hass, 'light', {'light': [ + {'platform': 'switch', 'entity_id': 'switch.decorative_lights'} + ]}) + await hass.async_block_till_done() + + assert hass.states.get('light.light_switch').state == 'on' + + switch_common.async_turn_off(hass, 'switch.decorative_lights') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'off' + assert hass.states.get('light.light_switch').state == 'off' + + switch_common.async_turn_on(hass, 'switch.decorative_lights') + await hass.async_block_till_done() + + assert hass.states.get('switch.decorative_lights').state == 'on' + assert hass.states.get('light.light_switch').state == 'on' diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py index 347005c75ac..58f328c5b9d 100644 --- a/tests/components/lock/test_mqtt.py +++ b/tests/components/lock/test_mqtt.py @@ -2,10 +2,10 @@ from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.lock as lock +from homeassistant.components import lock, mqtt from homeassistant.components.mqtt.discovery import async_start -from tests.common import async_fire_mqtt_message +from tests.common import async_fire_mqtt_message, MockConfigEntry async def test_controlling_state_via_topic(hass, mqtt_mock): @@ -136,7 +136,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_discovery_removal_lock(hass, mqtt_mock, caplog): """Test removal of discovered lock.""" - await async_start(hass, 'homeassistant', {'mqtt': {}}) + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) data = ( '{ "name": "Beer",' ' "command_topic": "test_topic" }' diff --git a/tests/components/lock/test_zwave.py b/tests/components/lock/test_zwave.py index 83aec7f0ce9..3955538273b 100644 --- a/tests/components/lock/test_zwave.py +++ b/tests/components/lock/test_zwave.py @@ -1,8 +1,7 @@ """Test Z-Wave locks.""" -import asyncio - from unittest.mock import patch, MagicMock +from homeassistant import config_entries from homeassistant.components.lock import zwave from homeassistant.components.zwave import const @@ -63,6 +62,22 @@ def test_lock_value_changed(mock_openzwave): assert device.is_locked +def test_lock_value_changed_workaround(mock_openzwave): + """Test value changed for Z-Wave lock using notification state.""" + node = MockNode(manufacturer_id='0090', product_id='0440') + values = MockEntityValues( + primary=MockValue(data=True, node=node), + access_control=MockValue(data=1, node=node), + alarm_type=None, + alarm_level=None, + ) + device = zwave.get_device(node=node, values=values) + assert device.is_locked + values.access_control.data = 2 + value_changed(values.access_control) + assert not device.is_locked + + def test_v2btze_value_changed(mock_openzwave): """Test value changed for v2btze Z-Wave lock.""" node = MockNode(manufacturer_id='010e', product_id='0002') @@ -168,15 +183,25 @@ def test_lock_alarm_level(mock_openzwave): 'Tamper Alarm: Too many keypresses' -@asyncio.coroutine -def test_lock_set_usercode_service(hass, mock_openzwave): +async def setup_ozw(hass, mock_openzwave): + """Set up the mock ZWave config entry.""" + hass.config.components.add('zwave') + config_entry = config_entries.ConfigEntry(1, 'zwave', 'Mock Title', { + 'usb_path': 'mock-path', + 'network_key': 'mock-key' + }, 'test', config_entries.CONN_CLASS_LOCAL_PUSH) + await hass.config_entries.async_forward_entry_setup(config_entry, + 'lock') + await hass.async_block_till_done() + + +async def test_lock_set_usercode_service(hass, mock_openzwave): """Test the zwave lock set_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() + node = MockNode(node_id=12) value0 = MockValue(data=' ', node=node, index=0) value1 = MockValue(data=' ', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, @@ -186,68 +211,69 @@ def test_lock_set_usercode_service(hass, mock_openzwave): mock_network.nodes = { node.node_id: node } - yield from hass.services.async_call( + + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() + + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '1234', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' mock_network.nodes = { node.node_id: node } - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_SET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_USERCODE: '123', zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '1234' -@asyncio.coroutine -def test_lock_get_usercode_service(hass, mock_openzwave): +async def test_lock_get_usercode_service(hass, mock_openzwave): """Test the zwave lock get_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='1234', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, value1.value_id: value1, } + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() + with patch.object(zwave, '_LOGGER') as mock_logger: mock_network.nodes = {node.node_id: node} - yield from hass.services.async_call( + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_GET_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1, }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() # This service only seems to write to the log assert mock_logger.info.called assert len(mock_logger.info.mock_calls) == 1 assert mock_logger.info.mock_calls[0][1][2] == '1234' -@asyncio.coroutine -def test_lock_clear_usercode_service(hass, mock_openzwave): +async def test_lock_clear_usercode_service(hass, mock_openzwave): """Test the zwave lock clear_usercode service.""" mock_network = hass.data[zwave.zwave.DATA_NETWORK] = MagicMock() node = MockNode(node_id=12) value0 = MockValue(data=None, node=node, index=0) value1 = MockValue(data='123', node=node, index=1) - yield from zwave.async_setup_platform( - hass, {}, MagicMock()) node.get_values.return_value = { value0.value_id: value0, @@ -257,11 +283,15 @@ def test_lock_clear_usercode_service(hass, mock_openzwave): mock_network.nodes = { node.node_id: node } - yield from hass.services.async_call( + + await setup_ozw(hass, mock_openzwave) + await hass.async_block_till_done() + + await hass.services.async_call( zwave.DOMAIN, zwave.SERVICE_CLEAR_USERCODE, { const.ATTR_NODE_ID: node.node_id, zwave.ATTR_CODE_SLOT: 1 }) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert value1.data == '\0\0\0' diff --git a/tests/components/luftdaten/__init__.py b/tests/components/luftdaten/__init__.py new file mode 100644 index 00000000000..d4249f69da2 --- /dev/null +++ b/tests/components/luftdaten/__init__.py @@ -0,0 +1 @@ +"""Define tests for the Luftdaten component.""" diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py new file mode 100644 index 00000000000..5c005507fbc --- /dev/null +++ b/tests/components/luftdaten/test_config_flow.py @@ -0,0 +1,114 @@ +"""Define tests for the Luftdaten config flow.""" +from datetime import timedelta +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.luftdaten import DOMAIN, config_flow +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP + +from tests.common import MockConfigEntry, mock_coro + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'sensor_exists'} + + +async def test_communication_error(hass): + """Test that no sensor is added while unable to communicate with API.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(None)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'} + + +async def test_invalid_sensor(hass): + """Test that an invalid sensor throws an error.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(False)),\ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(False)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(True)): + result = await flow.async_step_import(import_config=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345abcde' + assert result['data'] == { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 600, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: timedelta(minutes=5), + } + + flow = config_flow.LuftDatenFlowHandler() + flow.hass = hass + + with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \ + patch('luftdaten.Luftdaten.validate_sensor', + return_value=mock_coro(True)): + result = await flow.async_step_user(user_input=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '12345abcde' + assert result['data'] == { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 300, + } diff --git a/tests/components/luftdaten/test_init.py b/tests/components/luftdaten/test_init.py new file mode 100644 index 00000000000..eb2c0895c59 --- /dev/null +++ b/tests/components/luftdaten/test_init.py @@ -0,0 +1,36 @@ +"""Test the Luftdaten component setup.""" +from unittest.mock import patch + +from homeassistant.components import luftdaten +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN +from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP +from homeassistant.setup import async_setup_component + + +async def test_config_with_sensor_passed_to_config_entry(hass): + """Test that configured options for a sensor are loaded.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + CONF_SHOW_ON_MAP: False, + CONF_SCAN_INTERVAL: 600, + } + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(luftdaten, 'configured_sensors', return_value=[]): + assert await async_setup_component(hass, DOMAIN, conf) is True + + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered sensor does not initiate an import.""" + conf = { + CONF_SENSOR_ID: '12345abcde', + } + + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(luftdaten, 'configured_sensors', + return_value=['12345abcde']): + assert await async_setup_component(hass, DOMAIN, conf) is True + + assert len(mock_config_entries.flow.mock_calls) == 0 diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index 4049ba66a3c..c2f5d28fd5d 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -12,8 +12,8 @@ from homeassistant.components.media_player import SUPPORT_TURN_ON, \ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL from homeassistant.components.media_player.samsungtv import setup_platform, \ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_MAC, \ - STATE_OFF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \ + CONF_MAC, STATE_OFF from tests.common import MockDependency from homeassistant.util import dt as dt_util from datetime import timedelta @@ -103,7 +103,7 @@ class TestSamsungTv(unittest.TestCase): def test_update_on(self): """Testing update tv on.""" self.device.update() - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) def test_update_off(self): """Testing update tv off.""" @@ -117,7 +117,7 @@ class TestSamsungTv(unittest.TestCase): def test_send_key(self): """Test for send key.""" self.device.send_key('KEY_POWER') - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) def test_send_key_broken_pipe(self): """Testing broken pipe Exception.""" @@ -126,8 +126,8 @@ class TestSamsungTv(unittest.TestCase): side_effect=BrokenPipeError('Boom')) self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') - assert self.device._remote is None - assert self.device._state is None + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) def test_send_key_connection_closed_retry_succeed(self): """Test retry on connection closed.""" @@ -138,7 +138,7 @@ class TestSamsungTv(unittest.TestCase): self.device.get_remote = mock.Mock(return_value=_remote) command = 'HELLO' self.device.send_key(command) - assert self.device._state is None + self.assertEqual(STATE_ON, self.device._state) # verify that _remote.control() get called twice because of retry logic expected = [mock.call(command), mock.call(command)] @@ -152,8 +152,8 @@ class TestSamsungTv(unittest.TestCase): ) self.device.get_remote = mock.Mock(return_value=_remote) self.device.send_key('HELLO') - assert self.device._remote is None - assert self.device._state is None + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) def test_send_key_os_error(self): """Testing broken pipe Exception.""" @@ -178,8 +178,8 @@ class TestSamsungTv(unittest.TestCase): def test_state(self): """Test for state property.""" - self.device._state = None - assert self.device.state is None + self.device._state = STATE_ON + self.assertEqual(STATE_ON, self.device.state) self.device._state = STATE_OFF assert STATE_OFF == self.device.state diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index b075b8db063..083227e27c0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -185,7 +185,7 @@ def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): assert state is not None assert state.name == 'Beer' - assert ('binary_sensor', 'my_node_id_bla') in hass.data[ALREADY_DISCOVERED] + assert ('binary_sensor', 'my_node_id bla') in hass.data[ALREADY_DISCOVERED] @asyncio.coroutine diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py new file mode 100644 index 00000000000..102b71d7b53 --- /dev/null +++ b/tests/components/mqtt/test_subscription.py @@ -0,0 +1,180 @@ +"""The tests for the MQTT subscription component.""" +from homeassistant.core import callback +from homeassistant.components.mqtt.subscription import ( + async_subscribe_topics, async_unsubscribe_topics) + +from tests.common import async_fire_mqtt_message, async_mock_mqtt_component + + +async def test_subscribe_topics(hass, mqtt_mock, caplog): + """Test subscription to topics.""" + calls1 = [] + + @callback + def record_calls1(*args): + """Record calls.""" + calls1.append(args) + + calls2 = [] + + @callback + def record_calls2(*args): + """Record calls.""" + calls2.append(args) + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': record_calls1}, + 'test_topic2': {'topic': 'test-topic2', + 'msg_callback': record_calls2}}) + + 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 0 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload2') + await hass.async_block_till_done() + 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] + + await async_unsubscribe_topics(hass, sub_state) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + +async def test_modify_topics(hass, mqtt_mock, caplog): + """Test modification of topics.""" + calls1 = [] + + @callback + def record_calls1(*args): + """Record calls.""" + calls1.append(args) + + calls2 = [] + + @callback + def record_calls2(*args): + """Record calls.""" + calls2.append(args) + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': record_calls1}, + 'test_topic2': {'topic': 'test-topic2', + 'msg_callback': record_calls2}}) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 0 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1_1', + 'msg_callback': record_calls1}}) + + async_fire_mqtt_message(hass, 'test-topic1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + await hass.async_block_till_done() + await hass.async_block_till_done() + assert 1 == len(calls1) + assert 1 == len(calls2) + + async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload') + 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 1 == len(calls2) + + await async_unsubscribe_topics(hass, sub_state) + + async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload') + async_fire_mqtt_message(hass, 'test-topic2', 'test-payload') + + await hass.async_block_till_done() + assert 2 == len(calls1) + assert 1 == len(calls2) + + +async def test_qos_encoding_default(hass, mqtt_mock, caplog): + """Test default qos and encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic1', msg_callback, 0, 'utf-8') + + +async def test_qos_encoding_custom(hass, mqtt_mock, caplog): + """Test custom qos and encoding.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback, + 'qos': 1, + 'encoding': 'utf-16'}}) + mock_mqtt.async_subscribe.assert_called_once_with( + 'test-topic1', msg_callback, 1, 'utf-16') + + +async def test_no_change(hass, mqtt_mock, caplog): + """Test subscription to topics without change.""" + mock_mqtt = await async_mock_mqtt_component(hass) + + @callback + def msg_callback(*args): + """Do nothing.""" + pass + + sub_state = None + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + call_count = mock_mqtt.async_subscribe.call_count + sub_state = await async_subscribe_topics( + hass, sub_state, + {'test_topic1': {'topic': 'test-topic1', + 'msg_callback': msg_callback}}) + assert call_count == mock_mqtt.async_subscribe.call_count diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py new file mode 100644 index 00000000000..a95431913b2 --- /dev/null +++ b/tests/components/owntracks/__init__.py @@ -0,0 +1 @@ +"""Tests for OwnTracks component.""" diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py new file mode 100644 index 00000000000..079fdfafea0 --- /dev/null +++ b/tests/components/owntracks/test_config_flow.py @@ -0,0 +1 @@ +"""Tests for OwnTracks config flow.""" diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py new file mode 100644 index 00000000000..ee79c8b9e10 --- /dev/null +++ b/tests/components/owntracks/test_init.py @@ -0,0 +1,149 @@ +"""Test the owntracks_http platform.""" +import asyncio + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import mock_component, MockConfigEntry + +MINIMAL_LOCATION_MESSAGE = { + '_type': 'location', + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 'tst': 1, +} + +LOCATION_MESSAGE = { + '_type': 'location', + 'acc': 60, + 'alt': 27, + 'batt': 92, + 'cog': 248, + 'lon': 45, + 'lat': 90, + 'p': 101.3977584838867, + 'tid': 'test', + 't': 'u', + 'tst': 1, + 'vac': 4, + 'vel': 0 +} + + +@pytest.fixture +def mock_client(hass, aiohttp_client): + """Start the Hass HTTP component.""" + mock_component(hass, 'group') + mock_component(hass, 'zone') + mock_component(hass, 'device_tracker') + + MockConfigEntry(domain='owntracks', data={ + 'webhook_id': 'owntracks_test', + 'secret': 'abcd', + }).add_to_hass(hass) + hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {})) + + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@asyncio.coroutine +def test_handle_valid_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == [] + + +@asyncio.coroutine +def test_handle_valid_minimal_message(mock_client): + """Test that we forward messages correctly to OwnTracks.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=MINIMAL_LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == [] + + +@asyncio.coroutine +def test_handle_value_error(mock_client): + """Test we don't disclose that this is a valid webhook.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json='', + headers={ + 'X-Limit-u': 'Paulus', + 'X-Limit-d': 'Pixel', + } + ) + + assert resp.status == 200 + + json = yield from resp.text() + assert json == "" + + +@asyncio.coroutine +def test_returns_error_missing_username(mock_client): + """Test that an error is returned when username is missing.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-d': 'Pixel', + } + ) + + assert resp.status == 400 + + json = yield from resp.json() + assert json == {'error': 'You need to supply username.'} + + +@asyncio.coroutine +def test_returns_error_missing_device(mock_client): + """Test that an error is returned when device name is missing.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + json=LOCATION_MESSAGE, + headers={ + 'X-Limit-u': 'Paulus', + } + ) + + assert resp.status == 200 + + json = yield from resp.json() + assert json == [] + + +async def test_config_flow_import(hass): + """Test that we automatically create a config flow.""" + assert not hass.config_entries.async_entries('owntracks') + assert await async_setup_component(hass, 'owntracks', { + 'owntracks': { + + } + }) + await hass.async_block_till_done() + assert hass.config_entries.async_entries('owntracks') diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py new file mode 100644 index 00000000000..9fb6eea9ac7 --- /dev/null +++ b/tests/components/point/__init__.py @@ -0,0 +1 @@ +"""Tests for the Point component.""" diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py new file mode 100644 index 00000000000..cf9f3b2dbdd --- /dev/null +++ b/tests/components/point/test_config_flow.py @@ -0,0 +1,147 @@ +"""Tests for the Point config flow.""" +import asyncio +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.point import DOMAIN, config_flow + +from tests.common import MockDependency, mock_coro + + +def init_config_flow(hass, side_effect=None): + """Init a configuration flow.""" + config_flow.register_flow_implementation(hass, DOMAIN, 'id', 'secret') + flow = config_flow.PointFlowHandler() + flow._get_authorization_url = Mock( # pylint: disable=W0212 + return_value=mock_coro('https://example.com'), + side_effect=side_effect) + flow.hass = hass + return flow + + +@pytest.fixture +def is_authorized(): + """Set PointSession authorized.""" + return True + + +@pytest.fixture +def mock_pypoint(is_authorized): # pylint: disable=W0621 + """Mock pypoint.""" + with MockDependency('pypoint') as mock_pypoint_: + mock_pypoint_.PointSession().get_access_token.return_value = { + 'access_token': 'boo' + } + mock_pypoint_.PointSession().is_authorized = is_authorized + mock_pypoint_.PointSession().user.return_value = { + 'email': 'john.doe@example.com' + } + yield mock_pypoint_ + + +async def test_abort_if_no_implementation_registered(hass): + """Test we abort if no implementation is registered.""" + flow = config_flow.PointFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_flows' + + +async def test_abort_if_already_setup(hass): + """Test we abort if Point is already setup.""" + flow = init_config_flow(hass) + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test registering an implementation and finishing flow works.""" + config_flow.register_flow_implementation(hass, 'test-other', None, None) + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + result = await flow.async_step_user({'flow_impl': 'test'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + assert result['description_placeholders'] == { + 'authorization_url': 'https://example.com', + } + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['refresh_args'] == { + 'client_id': 'id', + 'client_secret': 'secret' + } + assert result['title'] == 'john.doe@example.com' + assert result['data']['token'] == {'access_token': 'boo'} + + +async def test_step_import(hass, mock_pypoint): # pylint: disable=W0621 + """Test that we trigger import when configuring with client.""" + flow = init_config_flow(hass) + + result = await flow.async_step_import() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +@pytest.mark.parametrize('is_authorized', [False]) +async def test_wrong_code_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621 + """Test wrong code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code('123ABC') + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'auth_error' + + +async def test_not_pick_implementation_if_only_one(hass): + """Test we allow picking implementation if we have one flow_imp.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'auth' + + +async def test_abort_if_timeout_generating_auth_url(hass): + """Test we abort if generating authorize url fails.""" + flow = init_config_flow(hass, side_effect=asyncio.TimeoutError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' + + +async def test_abort_if_exception_generating_auth_url(hass): + """Test we abort if generating authorize url blows up.""" + flow = init_config_flow(hass, side_effect=ValueError) + + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_fail' + + +async def test_abort_no_code(hass): + """Test if no code is given to step_code.""" + flow = init_config_flow(hass) + + result = await flow.async_step_code() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_code' diff --git a/tests/components/rainmachine/__init__.py b/tests/components/rainmachine/__init__.py new file mode 100644 index 00000000000..d6bd6a5dd95 --- /dev/null +++ b/tests/components/rainmachine/__init__.py @@ -0,0 +1 @@ +"""Define tests for the RainMachine component.""" diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py new file mode 100644 index 00000000000..2291ac23749 --- /dev/null +++ b/tests/components/rainmachine/test_config_flow.py @@ -0,0 +1,109 @@ +"""Define tests for the OpenUV config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.rainmachine import DOMAIN, config_flow +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_SCAN_INTERVAL) + +from tests.common import MockConfigEntry, mock_coro + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_IP_ADDRESS: 'identifier_exists'} + + +async def test_invalid_password(hass): + """Test that an invalid password throws an error.""" + from regenmaschine.errors import RainMachineError + + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'bad_password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', + return_value=mock_coro(exception=RainMachineError)): + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_PASSWORD: 'invalid_credentials'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', return_value=mock_coro(True)): + result = await flow.async_step_import(import_config=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '192.168.1.100' + assert result['data'] == { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + } + + flow = config_flow.RainMachineFlowHandler() + flow.hass = hass + + with patch('regenmaschine.login', return_value=mock_coro(True)): + result = await flow.async_step_user(user_input=conf) + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '192.168.1.100' + assert result['data'] == { + CONF_IP_ADDRESS: '192.168.1.100', + CONF_PASSWORD: 'password', + CONF_PORT: 8080, + CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, + } diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index ccfe4344373..33a13f013de 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -20,7 +20,7 @@ VALID_CONFIG_MINIMAL = { 'platform': 'darksky', 'api_key': 'foo', 'forecast': [1, 2], - 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'monitored_conditions': ['summary', 'icon', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -30,7 +30,7 @@ INVALID_CONFIG_MINIMAL = { 'platform': 'darksky', 'api_key': 'foo', 'forecast': [1, 2], - 'monitored_conditions': ['sumary', 'iocn', 'temperature_max'], + 'monitored_conditions': ['sumary', 'iocn', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -42,7 +42,7 @@ VALID_CONFIG_LANG_DE = { 'forecast': [1, 2], 'units': 'us', 'language': 'de', - 'monitored_conditions': ['summary', 'icon', 'temperature_max', + 'monitored_conditions': ['summary', 'icon', 'temperature_high', 'minutely_summary', 'hourly_summary', 'daily_summary', 'humidity', ], 'update_interval': timedelta(seconds=120), @@ -55,7 +55,7 @@ INVALID_CONFIG_LANG = { 'api_key': 'foo', 'forecast': [1, 2], 'language': 'yz', - 'monitored_conditions': ['summary', 'icon', 'temperature_max'], + 'monitored_conditions': ['summary', 'icon', 'temperature_high'], 'update_interval': timedelta(seconds=120), } } @@ -154,7 +154,7 @@ class TestDarkSkySetup(unittest.TestCase): assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - assert len(self.hass.states.entity_ids()) == 9 + assert len(self.hass.states.entity_ids()) == 8 state = self.hass.states.get('sensor.dark_sky_summary') assert state is not None diff --git a/tests/components/sensor/test_jewish_calendar.py b/tests/components/sensor/test_jewish_calendar.py index 47874d1da42..320bc903661 100644 --- a/tests/components/sensor/test_jewish_calendar.py +++ b/tests/components/sensor/test_jewish_calendar.py @@ -64,7 +64,7 @@ class TestJewishCalenderSensor(): (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "english", "date", False, "23 Elul 5778"), (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "hebrew", "date", - False, "כ\"ג באלול ה\' תשע\"ח"), + False, "כ\"ג אלול ה\' תשע\"ח"), (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "hebrew", "holiday_name", False, "א\' ראש השנה"), (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holiday_name", @@ -72,17 +72,17 @@ class TestJewishCalenderSensor(): (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holyness", False, 1), (dt(2018, 9, 8), 'UTC', 31.778, 35.235, "hebrew", "weekly_portion", - False, "פרשת נצבים"), + False, "נצבים"), (dt(2018, 9, 8), 'America/New_York', 40.7128, -74.0060, "hebrew", "first_stars", True, time(19, 48)), (dt(2018, 9, 8), "Asia/Jerusalem", 31.778, 35.235, "hebrew", "first_stars", False, time(19, 21)), (dt(2018, 10, 14), "Asia/Jerusalem", 31.778, 35.235, "hebrew", - "weekly_portion", False, "פרשת לך לך"), + "weekly_portion", False, "לך לך"), (dt(2018, 10, 14, 17, 0, 0), "Asia/Jerusalem", 31.778, 35.235, - "hebrew", "date", False, "ה\' בחשון ה\' תשע\"ט"), + "hebrew", "date", False, "ה\' מרחשוון ה\' תשע\"ט"), (dt(2018, 10, 14, 19, 0, 0), "Asia/Jerusalem", 31.778, 35.235, - "hebrew", "date", False, "ו\' בחשון ה\' תשע\"ט") + "hebrew", "date", False, "ו\' מרחשוון ה\' תשע\"ט") ] test_ids = [ diff --git a/tests/components/sensor/test_melissa.py b/tests/components/sensor/test_melissa.py deleted file mode 100644 index 024e2e564eb..00000000000 --- a/tests/components/sensor/test_melissa.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Test for Melissa climate component.""" -import json -from unittest.mock import Mock, patch - -from homeassistant.components.sensor.melissa import MelissaTemperatureSensor, \ - MelissaHumiditySensor - -from tests.common import load_fixture, mock_coro_func - -from homeassistant.components.melissa import DATA_MELISSA -from homeassistant.components.sensor import melissa -from homeassistant.const import TEMP_CELSIUS - - -_SERIAL = "12345678" - - -def melissa_mock(): - """Use this to mock the melissa api.""" - api = Mock() - api.async_fetch_devices = mock_coro_func( - return_value=json.loads(load_fixture('melissa_fetch_devices.json'))) - api.async_status = mock_coro_func(return_value=json.loads(load_fixture( - 'melissa_status.json' - ))) - - api.TEMP = 'temp' - api.HUMIDITY = 'humidity' - return api - - -async def test_setup_platform(hass): - """Test setup_platform.""" - with patch('homeassistant.components.melissa'): - hass.data[DATA_MELISSA] = melissa_mock() - - config = {} - async_add_entities = mock_coro_func() - discovery_info = {} - - await melissa.async_setup_platform( - hass, config, async_add_entities, discovery_info) - - -async def test_name(hass): - """Test name property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - - assert temp.name == '{0} {1}'.format( - device['name'], - temp._type - ) - assert hum.name == '{0} {1}'.format( - device['name'], - hum._type - ) - - -async def test_state(hass): - """Test state property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - status = (await mocked_melissa.async_status())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - await temp.async_update() - assert temp.state == status[mocked_melissa.TEMP] - await hum.async_update() - assert hum.state == status[mocked_melissa.HUMIDITY] - - -async def test_unit_of_measurement(hass): - """Test unit of measurement property.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - assert temp.unit_of_measurement == TEMP_CELSIUS - assert hum.unit_of_measurement == '%' - - -async def test_update(hass): - """Test for update.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - await temp.async_update() - assert temp.state == 27.4 - await hum.async_update() - assert hum.state == 18.7 - - -async def test_update_keyerror(hass): - """Test for faulty update.""" - with patch('homeassistant.components.melissa'): - mocked_melissa = melissa_mock() - device = (await mocked_melissa.async_fetch_devices())[_SERIAL] - temp = MelissaTemperatureSensor(device, mocked_melissa) - hum = MelissaHumiditySensor(device, mocked_melissa) - mocked_melissa.async_status = mock_coro_func(return_value={}) - await temp.async_update() - assert temp.state is None - await hum.async_update() - assert hum.state is None diff --git a/tests/components/sensor/test_srp_energy.py b/tests/components/sensor/test_srp_energy.py new file mode 100644 index 00000000000..8b92e9e9467 --- /dev/null +++ b/tests/components/sensor/test_srp_energy.py @@ -0,0 +1,62 @@ +"""The tests for the Srp Energy Platform.""" +from unittest.mock import patch +import logging +from homeassistant.setup import async_setup_component + +_LOGGER = logging.getLogger(__name__) + +VALID_CONFIG_MINIMAL = { + 'sensor': { + 'platform': 'srp_energy', + 'username': 'foo', + 'password': 'bar', + 'id': 1234 + } +} + +PATCH_INIT = 'srpenergy.client.SrpEnergyClient.__init__' +PATCH_VALIDATE = 'srpenergy.client.SrpEnergyClient.validate' +PATCH_USAGE = 'srpenergy.client.SrpEnergyClient.usage' + + +def mock_usage(self, startdate, enddate): # pylint: disable=invalid-name + """Mock srpusage usage.""" + _LOGGER.log(logging.INFO, "Calling mock usage") + usage = [ + ('9/19/2018', '12:00 AM', '2018-09-19T00:00:00-7:00', '1.2', '0.17'), + ('9/19/2018', '1:00 AM', '2018-09-19T01:00:00-7:00', '2.1', '0.30'), + ('9/19/2018', '2:00 AM', '2018-09-19T02:00:00-7:00', '1.5', '0.23'), + ('9/19/2018', '9:00 PM', '2018-09-19T21:00:00-7:00', '1.2', '0.19'), + ('9/19/2018', '10:00 PM', '2018-09-19T22:00:00-7:00', '1.1', '0.18'), + ('9/19/2018', '11:00 PM', '2018-09-19T23:00:00-7:00', '0.4', '0.09') + ] + return usage + + +async def test_setup_with_config(hass): + """Test the platform setup with configuration.""" + with patch(PATCH_INIT, return_value=None), \ + patch(PATCH_VALIDATE, return_value=True), \ + patch(PATCH_USAGE, new=mock_usage): + + await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL) + + state = hass.states.get('sensor.srp_energy') + assert state is not None + + +async def test_daily_usage(hass): + """Test the platform daily usage.""" + with patch(PATCH_INIT, return_value=None), \ + patch(PATCH_VALIDATE, return_value=True), \ + patch(PATCH_USAGE, new=mock_usage): + + await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL) + + state = hass.states.get('sensor.srp_energy') + + assert state + assert state.state == '7.50' + + assert state.attributes + assert state.attributes.get('unit_of_measurement') diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 0bf9ecd8c6f..5d1137c35e6 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -3,6 +3,7 @@ import unittest import statistics from homeassistant.setup import setup_component +from homeassistant.components.sensor.statistics import StatisticsSensor from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN) from homeassistant.util import dt as dt_util @@ -48,6 +49,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in values: self.hass.states.set('binary_sensor.test_monitored', value) self.hass.block_till_done() @@ -66,6 +70,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -99,6 +106,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -120,6 +130,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values[-3:]: # just the last 3 will do self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -161,6 +174,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -193,6 +209,9 @@ class TestStatisticsSensor(unittest.TestCase): } }) + self.hass.start() + self.hass.block_till_done() + for value in self.values: self.hass.states.set('sensor.test_monitored', value, {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) @@ -230,6 +249,71 @@ class TestStatisticsSensor(unittest.TestCase): 'sampling_size': 100, } }) + + self.hass.start() + self.hass.block_till_done() + # check if the result is as in test_sensor_source() state = self.hass.states.get('sensor.test_mean') assert str(self.mean) == state.state + + def test_initialize_from_database_with_maxage(self): + """Test initializing the statistics from the database.""" + mock_data = { + 'return_time': datetime(2017, 8, 2, 12, 23, 42, + tzinfo=dt_util.UTC), + } + + def mock_now(): + return mock_data['return_time'] + + # Testing correct retrieval from recorder, thus we do not + # want purging to occur within the class itself. + def mock_purge(self): + return + + # Set maximum age to 3 hours. + max_age = 3 + # Determine what our minimum age should be based on test values. + expected_min_age = mock_data['return_time'] + \ + timedelta(hours=len(self.values) - max_age) + + # enable the recorder + init_recorder_component(self.hass) + + with patch('homeassistant.components.sensor.statistics.dt_util.utcnow', + new=mock_now), \ + patch.object(StatisticsSensor, '_purge_old', mock_purge): + # store some values + for value in self.values: + self.hass.states.set('sensor.test_monitored', value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + self.hass.block_till_done() + # insert the next value 1 hour later + mock_data['return_time'] += timedelta(hours=1) + + # wait for the recorder to really store the data + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + # only now create the statistics component, so that it must read + # the data from the database + assert setup_component(self.hass, 'sensor', { + 'sensor': { + 'platform': 'statistics', + 'name': 'test', + 'entity_id': 'sensor.test_monitored', + 'sampling_size': 100, + 'max_age': {'hours': max_age} + } + }) + + self.hass.start() + self.hass.block_till_done() + + # check if the result is as in test_sensor_source() + state = self.hass.states.get('sensor.test_mean') + + assert expected_min_age == state.attributes.get('min_age') + # The max_age timestamp should be 1 hour before what we have right + # now in mock_data['return_time']. + assert mock_data['return_time'] == state.attributes.get('max_age') +\ + timedelta(hours=1) diff --git a/tests/components/sensor/test_transport_nsw.py b/tests/components/sensor/test_transport_nsw.py index c0ad4be4110..231e175893f 100644 --- a/tests/components/sensor/test_transport_nsw.py +++ b/tests/components/sensor/test_transport_nsw.py @@ -10,18 +10,21 @@ VALID_CONFIG = {'sensor': { 'platform': 'transport_nsw', 'stop_id': '209516', 'route': '199', + 'destination': '', 'api_key': 'YOUR_API_KEY'} } -def get_departuresMock(_stop_id, route, api_key): +def get_departuresMock(_stop_id, route, destination, api_key): """Mock TransportNSW departures loading.""" data = { 'stop_id': '209516', 'route': '199', 'due': 16, 'delay': 6, - 'real_time': 'y' + 'real_time': 'y', + 'destination': 'Palm Beach', + 'mode': 'Bus' } return data @@ -48,3 +51,5 @@ class TestRMVtransportSensor(unittest.TestCase): assert state.attributes['route'] == '199' assert state.attributes['delay'] == 6 assert state.attributes['real_time'] == 'y' + assert state.attributes['destination'] == 'Palm Beach' + assert state.attributes['mode'] == 'Bus' diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index 1a51457df96..d39c5a24ddc 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -91,7 +91,7 @@ class TestSwitch(unittest.TestCase): ) -async def test_switch_context(hass): +async def test_switch_context(hass, hass_admin_user): """Test that switch context works.""" assert await async_setup_component(hass, 'switch', { 'switch': { @@ -104,9 +104,9 @@ async def test_switch_context(hass): await hass.services.async_call('switch', 'toggle', { 'entity_id': state.entity_id, - }, True, core.Context(user_id='abcd')) + }, True, core.Context(user_id=hass_admin_user.id)) state2 = hass.states.get('switch.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 6f6b4e93068..3ebfa05a3d3 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -16,10 +16,12 @@ from tests.common import async_mock_service @pytest.fixture -def mock_api_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" +def mock_api_client(hass, aiohttp_client, hass_access_token): + """Start the Hass HTTP component and return admin API client.""" hass.loop.run_until_complete(async_setup_component(hass, 'api', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + })) @asyncio.coroutine @@ -405,7 +407,8 @@ def _listen_count(hass): return sum(hass.bus.async_listeners().values()) -async def test_api_error_log(hass, aiohttp_client): +async def test_api_error_log(hass, aiohttp_client, hass_access_token, + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' await async_setup_component(hass, 'api', { @@ -416,7 +419,7 @@ async def test_api_error_log(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) - # Verufy auth required + # Verify auth required assert resp.status == 401 with patch( @@ -424,7 +427,7 @@ async def test_api_error_log(hass, aiohttp_client): return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ - 'x-ha-access': 'yolo' + 'Authorization': 'Bearer {}'.format(hass_access_token) }) assert len(mock_file.mock_calls) == 1 @@ -432,6 +435,13 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 200 assert await resp.text() == 'Hello' + # Verify we require admin user + hass_admin_user.groups = [] + resp = await client.get(const.URL_API_ERROR_LOG, headers={ + 'Authorization': 'Bearer {}'.format(hass_access_token) + }) + assert resp.status == 401 + async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): @@ -494,3 +504,67 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): state = hass.states.get('light.kitchen') assert state.context.user_id == refresh_token.user.id + + +async def test_event_stream_requires_admin(hass, mock_api_client, + hass_admin_user): + """Test user needs to be admin to access event stream.""" + hass_admin_user.groups = [] + resp = await mock_api_client.get('/api/stream') + assert resp.status == 401 + + +async def test_states_view_filters(hass, mock_api_client, hass_admin_user): + """Test filtering only visible states.""" + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'test.entity': True + } + } + }) + hass.states.async_set('test.entity', 'hello') + hass.states.async_set('test.not_visible_entity', 'invisible') + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == 200 + json = await resp.json() + assert len(json) == 1 + assert json[0]['entity_id'] == 'test.entity' + + +async def test_get_entity_state_read_perm(hass, mock_api_client, + hass_admin_user): + """Test getting a state requires read permission.""" + hass_admin_user.mock_policy({}) + resp = await mock_api_client.get('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user): + """Test updating state requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/states/light.test') + assert resp.status == 401 + + +async def test_delete_entity_state_admin(hass, mock_api_client, + hass_admin_user): + """Test deleting entity requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.delete('/api/states/light.test') + assert resp.status == 401 + + +async def test_post_event_admin(hass, mock_api_client, hass_admin_user): + """Test sending event requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/events/state_changed') + assert resp.status == 401 + + +async def test_rendering_template_admin(hass, mock_api_client, + hass_admin_user): + """Test rendering a template requires admin.""" + hass_admin_user.groups = [] + resp = await mock_api_client.post('/api/template') + assert resp.status == 401 diff --git a/tests/components/huawei_lte.py b/tests/components/test_huawei_lte.py similarity index 100% rename from tests/components/huawei_lte.py rename to tests/components/test_huawei_lte.py diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py index 9fc9ceaefc1..019318c2693 100644 --- a/tests/components/test_input_boolean.py +++ b/tests/components/test_input_boolean.py @@ -1,136 +1,98 @@ """The tests for the input_boolean component.""" # pylint: disable=protected-access import asyncio -import unittest import logging from homeassistant.core import CoreState, State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.input_boolean import ( is_on, CONF_INITIAL, DOMAIN) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON) -from homeassistant.loader import bind_hass -from tests.common import ( - get_test_home_assistant, mock_component, mock_restore_cache) +from tests.common import mock_component, mock_restore_cache _LOGGER = logging.getLogger(__name__) -@bind_hass -def toggle(hass, entity_id): - """Set input_boolean to False. +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) -@bind_hass -def turn_on(hass, entity_id): - """Set input_boolean to True. +async def test_methods(hass): + """Test is_on, turn_on, turn_off methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': None, + }}) + entity_id = 'input_boolean.test_1' - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) + assert not is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert not is_on(hass, entity_id) + + await hass.services.async_call( + DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id}) + + await hass.async_block_till_done() + + assert is_on(hass, entity_id) -@bind_hass -def turn_off(hass, entity_id): - """Set input_boolean to False. +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - This is a legacy helper method. Do not use it for new tests. - """ - hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': None, + 'test_2': { + 'name': 'Hello World', + 'icon': 'mdi:work', + 'initial': True, + }, + }}) -class TestInputBoolean(unittest.TestCase): - """Test the input boolean module.""" + _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids()) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert count_start + 2 == len(hass.states.async_entity_ids()) - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + state_1 = hass.states.get('input_boolean.test_1') + state_2 = hass.states.get('input_boolean.test_2') - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] + assert state_1 is not None + assert state_2 is not None - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + assert STATE_OFF == state_1.state + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - def test_methods(self): - """Test is_on, turn_on, turn_off methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': None, - }}) - entity_id = 'input_boolean.test_1' - - assert not is_on(self.hass, entity_id) - - turn_on(self.hass, entity_id) - - self.hass.block_till_done() - - assert is_on(self.hass, entity_id) - - turn_off(self.hass, entity_id) - - self.hass.block_till_done() - - assert not is_on(self.hass, entity_id) - - toggle(self.hass, entity_id) - - self.hass.block_till_done() - - assert is_on(self.hass, entity_id) - - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids()) - - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': None, - 'test_2': { - 'name': 'Hello World', - 'icon': 'mdi:work', - 'initial': True, - }, - }}) - - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) - - assert count_start + 2 == len(self.hass.states.entity_ids()) - - state_1 = self.hass.states.get('input_boolean.test_1') - state_2 = self.hass.states.get('input_boolean.test_2') - - assert state_1 is not None - assert state_2 is not None - - assert STATE_OFF == state_1.state - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes - - assert STATE_ON == state_2.state - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert STATE_ON == state_2.state + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) @asyncio.coroutine @@ -185,7 +147,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'on' -async def test_input_boolean_context(hass): +async def test_input_boolean_context(hass, hass_admin_user): """Test that input_boolean context works.""" assert await async_setup_component(hass, 'input_boolean', { 'input_boolean': { @@ -198,9 +160,9 @@ async def test_input_boolean_context(hass): await hass.services.async_call('input_boolean', 'turn_off', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_boolean.ac') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index e7f6b50c43d..a61cefe34f2 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -1,15 +1,14 @@ """Tests for the Input slider component.""" # pylint: disable=protected-access import asyncio -import unittest import datetime from homeassistant.core import CoreState, State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.input_datetime import ( DOMAIN, ATTR_ENTITY_ID, ATTR_DATE, ATTR_TIME, SERVICE_SET_DATETIME) -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache async def async_set_datetime(hass, entity_id, dt_value): @@ -21,32 +20,19 @@ async def async_set_datetime(hass, entity_id, dt_value): }, blocking=True) -class TestInputDatetime(unittest.TestCase): - """Test the input datetime component.""" - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_invalid_configs(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_no_value': { - 'has_time': False, - 'has_date': False - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) +async def test_invalid_configs(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_no_value': { + 'has_time': False, + 'has_date': False + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) @asyncio.coroutine @@ -209,7 +195,7 @@ def test_restore_state(hass): assert state_bogus.state == str(initial) -async def test_input_datetime_context(hass): +async def test_input_datetime_context(hass, hass_admin_user): """Test that input_datetime context works.""" assert await async_setup_component(hass, 'input_datetime', { 'input_datetime': { @@ -225,9 +211,9 @@ async def test_input_datetime_context(hass): await hass.services.async_call('input_datetime', 'set_datetime', { 'entity_id': state.entity_id, 'date': '2018-01-02' - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_datetime.only_date') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_number.py b/tests/components/test_input_number.py index 3129b4445c7..70dfeec2e7f 100644 --- a/tests/components/test_input_number.py +++ b/tests/components/test_input_number.py @@ -1,7 +1,6 @@ """The tests for the Input number component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.core import CoreState, State, Context from homeassistant.components.input_number import ( @@ -9,9 +8,9 @@ from homeassistant.components.input_number import ( SERVICE_SET_VALUE) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -20,10 +19,11 @@ def set_value(hass, entity_id, value): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: entity_id, - ATTR_VALUE: value, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + })) @bind_hass @@ -32,9 +32,10 @@ def increment(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_INCREMENT, { - ATTR_ENTITY_ID: entity_id - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, { + ATTR_ENTITY_ID: entity_id + })) @bind_hass @@ -43,152 +44,144 @@ def decrement(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_DECREMENT, { - ATTR_ENTITY_ID: entity_id - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, { + ATTR_ENTITY_ID: entity_id + })) -class TestInputNumber(unittest.TestCase): - """Test the input number component.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +async def test_set_value(hass): + """Test set_value method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 50, + 'min': 0, + 'max': 100, + }, + }}) + entity_id = 'input_number.test_1' - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_1': { - 'min': 50, - 'max': 50, - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + state = hass.states.get(entity_id) + assert 50 == float(state.state) - def test_set_value(self): - """Test set_value method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'initial': 50, + set_value(hass, entity_id, '30.4') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 30.4 == float(state.state) + + set_value(hass, entity_id, '70') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 70 == float(state.state) + + set_value(hass, entity_id, '110') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 70 == float(state.state) + + +async def test_increment(hass): + """Test increment method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_2': { + 'initial': 50, + 'min': 0, + 'max': 51, + }, + }}) + entity_id = 'input_number.test_2' + + state = hass.states.get(entity_id) + assert 50 == float(state.state) + + increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 51 == float(state.state) + + increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 51 == float(state.state) + + +async def test_decrement(hass): + """Test decrement method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_3': { + 'initial': 50, + 'min': 49, + 'max': 100, + }, + }}) + entity_id = 'input_number.test_3' + + state = hass.states.get(entity_id) + assert 50 == float(state.state) + + decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 49 == float(state.state) + + decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 49 == float(state.state) + + +async def test_mode(hass): + """Test mode settings.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_default_slider': { 'min': 0, 'max': 100, }, - }}) - entity_id = 'input_number.test_1' - - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) - - set_value(self.hass, entity_id, '30.4') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 30.4 == float(state.state) - - set_value(self.hass, entity_id, '70') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 70 == float(state.state) - - set_value(self.hass, entity_id, '110') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 70 == float(state.state) - - def test_increment(self): - """Test increment method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_2': { - 'initial': 50, + 'test_explicit_box': { 'min': 0, - 'max': 51, - }, - }}) - entity_id = 'input_number.test_2' - - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 51 == float(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 51 == float(state.state) - - def test_decrement(self): - """Test decrement method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_3': { - 'initial': 50, - 'min': 49, 'max': 100, + 'mode': 'box', + }, + 'test_explicit_slider': { + 'min': 0, + 'max': 100, + 'mode': 'slider', }, }}) - entity_id = 'input_number.test_3' - state = self.hass.states.get(entity_id) - assert 50 == float(state.state) + state = hass.states.get('input_number.test_default_slider') + assert state + assert 'slider' == state.attributes['mode'] - decrement(self.hass, entity_id) - self.hass.block_till_done() + state = hass.states.get('input_number.test_explicit_box') + assert state + assert 'box' == state.attributes['mode'] - state = self.hass.states.get(entity_id) - assert 49 == float(state.state) - - decrement(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 49 == float(state.state) - - def test_mode(self): - """Test mode settings.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_default_slider': { - 'min': 0, - 'max': 100, - }, - 'test_explicit_box': { - 'min': 0, - 'max': 100, - 'mode': 'box', - }, - 'test_explicit_slider': { - 'min': 0, - 'max': 100, - 'mode': 'slider', - }, - }}) - - state = self.hass.states.get('input_number.test_default_slider') - assert state - assert 'slider' == state.attributes['mode'] - - state = self.hass.states.get('input_number.test_explicit_box') - assert state - assert 'box' == state.attributes['mode'] - - state = self.hass.states.get('input_number.test_explicit_slider') - assert state - assert 'slider' == state.attributes['mode'] + state = hass.states.get('input_number.test_explicit_slider') + assert state + assert 'slider' == state.attributes['mode'] @asyncio.coroutine @@ -273,7 +266,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert float(state.state) == 0 -async def test_input_number_context(hass): +async def test_input_number_context(hass, hass_admin_user): """Test that input_number context works.""" assert await async_setup_component(hass, 'input_number', { 'input_number': { @@ -289,9 +282,9 @@ async def test_input_number_context(hass): await hass.services.async_call('input_number', 'increment', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_number.b1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py index 684c526cbeb..528560edc04 100644 --- a/tests/components/test_input_select.py +++ b/tests/components/test_input_select.py @@ -1,7 +1,6 @@ """The tests for the Input select component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.loader import bind_hass from homeassistant.components.input_select import ( @@ -10,9 +9,9 @@ from homeassistant.components.input_select import ( from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON) from homeassistant.core import State, Context -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -21,10 +20,11 @@ def select_option(hass, entity_id, option): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_OPTION, { - ATTR_ENTITY_ID: entity_id, - ATTR_OPTION: option, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_OPTION, { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + })) @bind_hass @@ -33,9 +33,10 @@ def select_next(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_NEXT, { - ATTR_ENTITY_ID: entity_id, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_NEXT, { + ATTR_ENTITY_ID: entity_id, + })) @bind_hass @@ -44,205 +45,198 @@ def select_previous(hass, entity_id): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SELECT_PREVIOUS, { - ATTR_ENTITY_ID: entity_id, + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SELECT_PREVIOUS, { + ATTR_ENTITY_ID: entity_id, + })) + + +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + # {'bad_options': {'options': None}}, + {'bad_initial': { + 'options': [1, 2], + 'initial': 3, + }}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_select_option(hass): + """Test select_option methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'some option', + 'another option', + ], + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'some option' == state.state + + select_option(hass, entity_id, 'another option') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'another option' == state.state + + select_option(hass, entity_id, 'non existing option') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'another option' == state.state + + +async def test_select_next(hass): + """Test select_next methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'middle option' == state.state + + select_next(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'last option' == state.state + + select_next(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'first option' == state.state + + +async def test_select_previous(hass): + """Test select_previous methods.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' + + state = hass.states.get(entity_id) + assert 'middle option' == state.state + + select_previous(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'first option' == state.state + + select_previous(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'last option' == state.state + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + test_2_options = [ + 'Good Option', + 'Better Option', + 'Best Option', + ] + + assert await async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'test_1': { + 'options': [ + 1, + 2, + ], + }, + 'test_2': { + 'name': 'Hello World', + 'icon': 'mdi:work', + 'options': test_2_options, + 'initial': 'Better Option', + }, + } }) + assert count_start + 2 == len(hass.states.async_entity_ids()) -class TestInputSelect(unittest.TestCase): - """Test the input select component.""" + state_1 = hass.states.get('input_select.test_1') + state_2 = hass.states.get('input_select.test_2') - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + assert state_1 is not None + assert state_2 is not None - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() + assert '1' == state_1.state + assert ['1', '2'] == \ + state_1.attributes.get(ATTR_OPTIONS) + assert ATTR_ICON not in state_1.attributes - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - # {'bad_options': {'options': None}}, - {'bad_initial': { - 'options': [1, 2], - 'initial': 3, - }}, - ] + assert 'Better Option' == state_2.state + assert test_2_options == \ + state_2.attributes.get(ATTR_OPTIONS) + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - def test_select_option(self): - """Test select_option methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'some option', - 'another option', - ], - }, - }}) - entity_id = 'input_select.test_1' +async def test_set_options_service(hass): + """Test set_options service.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'options': [ + 'first option', + 'middle option', + 'last option', + ], + 'initial': 'middle option', + }, + }}) + entity_id = 'input_select.test_1' - state = self.hass.states.get(entity_id) - assert 'some option' == state.state + state = hass.states.get(entity_id) + assert 'middle option' == state.state - select_option(self.hass, entity_id, 'another option') - self.hass.block_till_done() + data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id} + await hass.services.async_call(DOMAIN, SERVICE_SET_OPTIONS, data) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 'another option' == state.state + state = hass.states.get(entity_id) + assert 'test1' == state.state - select_option(self.hass, entity_id, 'non existing option') - self.hass.block_till_done() + select_option(hass, entity_id, 'first option') + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert 'test1' == state.state - state = self.hass.states.get(entity_id) - assert 'another option' == state.state - - def test_select_next(self): - """Test select_next methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - select_next(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'last option' == state.state - - select_next(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'first option' == state.state - - def test_select_previous(self): - """Test select_previous methods.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - select_previous(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'first option' == state.state - - select_previous(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'last option' == state.state - - def test_config_options(self): - """Test configuration options.""" - count_start = len(self.hass.states.entity_ids()) - - test_2_options = [ - 'Good Option', - 'Better Option', - 'Best Option', - ] - - assert setup_component(self.hass, DOMAIN, { - DOMAIN: { - 'test_1': { - 'options': [ - 1, - 2, - ], - }, - 'test_2': { - 'name': 'Hello World', - 'icon': 'mdi:work', - 'options': test_2_options, - 'initial': 'Better Option', - }, - } - }) - - assert count_start + 2 == len(self.hass.states.entity_ids()) - - state_1 = self.hass.states.get('input_select.test_1') - state_2 = self.hass.states.get('input_select.test_2') - - assert state_1 is not None - assert state_2 is not None - - assert '1' == state_1.state - assert ['1', '2'] == \ - state_1.attributes.get(ATTR_OPTIONS) - assert ATTR_ICON not in state_1.attributes - - assert 'Better Option' == state_2.state - assert test_2_options == \ - state_2.attributes.get(ATTR_OPTIONS) - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - - def test_set_options_service(self): - """Test set_options service.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { - 'options': [ - 'first option', - 'middle option', - 'last option', - ], - 'initial': 'middle option', - }, - }}) - entity_id = 'input_select.test_1' - - state = self.hass.states.get(entity_id) - assert 'middle option' == state.state - - data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id} - self.hass.services.call(DOMAIN, SERVICE_SET_OPTIONS, data) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'test1' == state.state - - select_option(self.hass, entity_id, 'first option') - self.hass.block_till_done() - state = self.hass.states.get(entity_id) - assert 'test1' == state.state - - select_option(self.hass, entity_id, 'test2') - self.hass.block_till_done() - state = self.hass.states.get(entity_id) - assert 'test2' == state.state + select_option(hass, entity_id, 'test2') + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert 'test2' == state.state @asyncio.coroutine @@ -308,7 +302,7 @@ def test_initial_state_overrules_restore_state(hass): assert state.state == 'middle option' -async def test_input_select_context(hass): +async def test_input_select_context(hass, hass_admin_user): """Test that input_select context works.""" assert await async_setup_component(hass, 'input_select', { 'input_select': { @@ -327,9 +321,9 @@ async def test_input_select_context(hass): await hass.services.async_call('input_select', 'select_next', { 'entity_id': state.entity_id, - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_select.s1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_input_text.py b/tests/components/test_input_text.py index 110a3190b1f..f0dec42ccea 100644 --- a/tests/components/test_input_text.py +++ b/tests/components/test_input_text.py @@ -1,16 +1,15 @@ """The tests for the Input text component.""" # pylint: disable=protected-access import asyncio -import unittest from homeassistant.components.input_text import ( ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import CoreState, State, Context from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component -from tests.common import get_test_home_assistant, mock_restore_cache +from tests.common import mock_restore_cache @bind_hass @@ -19,98 +18,88 @@ def set_value(hass, entity_id, value): This is a legacy helper method. Do not use it for new tests. """ - hass.services.call(DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: entity_id, - ATTR_VALUE: value, - }) + hass.async_create_task(hass.services.async_call( + DOMAIN, SERVICE_SET_VALUE, { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: value, + })) -class TestInputText(unittest.TestCase): - """Test the input slider component.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + {}, + {'name with space': None}, + {'test_1': { + 'min': 50, + 'max': 50, + }}, + ] + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() +async def test_set_value(hass): + """Test set_value method.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_1': { + 'initial': 'test', + 'min': 3, + 'max': 10, + }, + }}) + entity_id = 'input_text.test_1' - def test_config(self): - """Test config.""" - invalid_configs = [ - None, - {}, - {'name with space': None}, - {'test_1': { - 'min': 50, - 'max': 50, - }}, - ] - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + state = hass.states.get(entity_id) + assert 'test' == str(state.state) - def test_set_value(self): - """Test set_value method.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_1': { + set_value(hass, entity_id, 'testing') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'testing' == str(state.state) + + set_value(hass, entity_id, 'testing too long') + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 'testing' == str(state.state) + + +async def test_mode(hass): + """Test mode settings.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: { + 'test_default_text': { 'initial': 'test', 'min': 3, 'max': 10, }, + 'test_explicit_text': { + 'initial': 'test', + 'min': 3, + 'max': 10, + 'mode': 'text', + }, + 'test_explicit_password': { + 'initial': 'test', + 'min': 3, + 'max': 10, + 'mode': 'password', + }, }}) - entity_id = 'input_text.test_1' - state = self.hass.states.get(entity_id) - assert 'test' == str(state.state) + state = hass.states.get('input_text.test_default_text') + assert state + assert 'text' == state.attributes['mode'] - set_value(self.hass, entity_id, 'testing') - self.hass.block_till_done() + state = hass.states.get('input_text.test_explicit_text') + assert state + assert 'text' == state.attributes['mode'] - state = self.hass.states.get(entity_id) - assert 'testing' == str(state.state) - - set_value(self.hass, entity_id, 'testing too long') - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 'testing' == str(state.state) - - def test_mode(self): - """Test mode settings.""" - assert setup_component(self.hass, DOMAIN, {DOMAIN: { - 'test_default_text': { - 'initial': 'test', - 'min': 3, - 'max': 10, - }, - 'test_explicit_text': { - 'initial': 'test', - 'min': 3, - 'max': 10, - 'mode': 'text', - }, - 'test_explicit_password': { - 'initial': 'test', - 'min': 3, - 'max': 10, - 'mode': 'password', - }, - }}) - - state = self.hass.states.get('input_text.test_default_text') - assert state - assert 'text' == state.attributes['mode'] - - state = self.hass.states.get('input_text.test_explicit_text') - assert state - assert 'text' == state.attributes['mode'] - - state = self.hass.states.get('input_text.test_explicit_password') - assert state - assert 'password' == state.attributes['mode'] + state = hass.states.get('input_text.test_explicit_password') + assert state + assert 'password' == state.attributes['mode'] @asyncio.coroutine @@ -195,7 +184,7 @@ def test_no_initial_state_and_no_restore_state(hass): assert str(state.state) == 'unknown' -async def test_input_text_context(hass): +async def test_input_text_context(hass, hass_admin_user): """Test that input_text context works.""" assert await async_setup_component(hass, 'input_text', { 'input_text': { @@ -211,9 +200,9 @@ async def test_input_text_context(hass): await hass.services.async_call('input_text', 'set_value', { 'entity_id': state.entity_id, 'value': 'new_value', - }, True, Context(user_id='abcd')) + }, True, Context(user_id=hass_admin_user.id)) state2 = hass.states.get('input_text.t1') assert state2 is not None assert state.state != state2.state - assert state2.context.user_id == 'abcd' + assert state2.context.user_id == hass_admin_user.id diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 89528c1772b..ae1e3d1d51a 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -62,6 +62,12 @@ class TestComponentLogbook(unittest.TestCase): # Our service call will unblock when the event listeners have been # scheduled. This means that they may not have been processed yet. self.hass.block_till_done() + self.hass.data[recorder.DATA_INSTANCE].block_till_done() + + events = list(logbook._get_events( + self.hass, {}, dt_util.utcnow() - timedelta(hours=1), + dt_util.utcnow() + timedelta(hours=1))) + assert len(events) == 2 assert 1 == len(calls) last_call = calls[-1] @@ -136,8 +142,10 @@ 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), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -158,8 +166,10 @@ 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), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -180,8 +190,10 @@ 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), {}) + events = logbook._exclude_events( + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -207,7 +219,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -233,7 +245,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_DOMAINS: ['switch', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -270,7 +282,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -296,7 +308,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: [entity_id2, ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -322,7 +334,7 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_DOMAINS: ['sensor', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA, eventB), - config[logbook.DOMAIN]) + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -356,15 +368,20 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_ENTITIES: ['sensor.bli', ]}}}) events = logbook._exclude_events( (ha.Event(EVENT_HOMEASSISTANT_START), eventA1, eventA2, eventA3, - eventB1, eventB2), config[logbook.DOMAIN]) + eventB1, eventB2), + logbook._generate_filter_from_config(config[logbook.DOMAIN])) entries = list(logbook.humanify(self.hass, events)) - assert 3 == len(entries) + assert 5 == len(entries) self.assert_entry(entries[0], name='Home Assistant', message='started', domain=ha.DOMAIN) - self.assert_entry(entries[1], pointA, 'blu', domain='sensor', + self.assert_entry(entries[1], pointA, 'bla', domain='switch', + entity_id=entity_id) + self.assert_entry(entries[2], pointA, 'blu', domain='sensor', entity_id=entity_id2) - self.assert_entry(entries[2], pointB, 'blu', domain='sensor', + self.assert_entry(entries[3], pointB, 'bla', domain='switch', + entity_id=entity_id) + self.assert_entry(entries[4], pointB, 'blu', domain='sensor', entity_id=entity_id2) def test_exclude_auto_groups(self): @@ -377,7 +394,9 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - events = logbook._exclude_events((eventA, eventB), {}) + events = logbook._exclude_events( + (eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) @@ -395,7 +414,9 @@ 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), {}) + events = logbook._exclude_events( + (eventA, eventB), + logbook._generate_filter_from_config({})) entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 3131ae092a3..e64b9a5ae26 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -6,6 +6,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.helpers import intent +from homeassistant.components.websocket_api.const import TYPE_RESULT @pytest.fixture(autouse=True) @@ -54,7 +55,7 @@ def test_recent_items_intent(hass): @asyncio.coroutine -def test_api_get_all(hass, aiohttp_client): +def test_deprecated_api_get_all(hass, aiohttp_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -77,6 +78,37 @@ def test_api_get_all(hass, aiohttp_client): assert not data[1]['complete'] +async def test_ws_get_items(hass, hass_ws_client): + """Test get shopping_list items websocket command.""" + await async_setup_component(hass, 'shopping_list', {}) + + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}} + ) + await intent.async_handle( + hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}} + ) + + client = await hass_ws_client(hass) + + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items', + }) + msg = await client.receive_json() + assert msg['success'] is True + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + data = msg['result'] + assert len(data) == 2 + assert data[0]['name'] == 'beer' + assert not data[0]['complete'] + assert data[1]['name'] == 'wine' + assert not data[1]['complete'] + + @asyncio.coroutine def test_api_update(hass, aiohttp_client): """Test the API.""" diff --git a/tests/components/test_webhook.py b/tests/components/test_webhook.py index 9434c3d98d5..c16fef3e059 100644 --- a/tests/components/test_webhook.py +++ b/tests/components/test_webhook.py @@ -22,7 +22,8 @@ async def test_unregistering_webhook(hass, mock_client): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) assert resp.status == 200 @@ -51,7 +52,7 @@ async def test_posting_webhook_nonexisting(hass, mock_client): async def test_posting_webhook_invalid_json(hass, mock_client): """Test posting to a nonexisting webhook.""" - hass.components.webhook.async_register('hello', None) + hass.components.webhook.async_register('test', "Test hook", 'hello', None) resp = await mock_client.post('/api/webhook/hello', data='not-json') assert resp.status == 200 @@ -65,7 +66,8 @@ async def test_posting_webhook_json(hass, mock_client): """Handle webhook.""" hooks.append((args[0], args[1], await args[2].text())) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={ 'data': True @@ -86,7 +88,8 @@ async def test_posting_webhook_no_data(hass, mock_client): """Handle webhook.""" hooks.append(args) - hass.components.webhook.async_register(webhook_id, handle) + hass.components.webhook.async_register( + 'test', "Test hook", webhook_id, handle) resp = await mock_client.post('/api/webhook/{}'.format(webhook_id)) assert resp.status == 200 @@ -94,3 +97,28 @@ async def test_posting_webhook_no_data(hass, mock_client): assert hooks[0][0] is hass assert hooks[0][1] == webhook_id assert await hooks[0][2].text() == '' + + +async def test_listing_webhook(hass, hass_ws_client, hass_access_token): + """Test unregistering a webhook.""" + assert await async_setup_component(hass, 'webhook', {}) + client = await hass_ws_client(hass, hass_access_token) + + hass.components.webhook.async_register( + 'test', "Test hook", "my-id", None) + + await client.send_json({ + 'id': 5, + 'type': 'webhook/list', + }) + + msg = await client.receive_json() + assert msg['id'] == 5 + assert msg['success'] + assert msg['result'] == [ + { + 'webhook_id': 'my-id', + 'domain': 'test', + 'name': 'Test hook' + } + ] diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py index c740783f4c0..3be211532ed 100644 --- a/tests/components/twilio/test_init.py +++ b/tests/components/twilio/test_init.py @@ -7,35 +7,36 @@ from homeassistant.core import callback from tests.common import MockDependency -@MockDependency('twilio', 'rest') -@MockDependency('twilio', 'twiml') async def test_config_flow_registers_webhook(hass, aiohttp_client): """Test setting up Twilio and sending webhook.""" - with patch('homeassistant.util.get_local_ip', return_value='example.com'): - result = await hass.config_entries.flow.async_init('twilio', context={ - 'source': 'user' + with MockDependency('twilio', 'rest'), MockDependency('twilio', 'twiml'): + with patch('homeassistant.util.get_local_ip', + return_value='example.com'): + result = await hass.config_entries.flow.async_init( + 'twilio', context={ + 'source': 'user' + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result + + result = await hass.config_entries.flow.async_configure( + result['flow_id'], {}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + webhook_id = result['result'].data['webhook_id'] + + twilio_events = [] + + @callback + def handle_event(event): + """Handle Twilio event.""" + twilio_events.append(event) + + hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) + + client = await aiohttp_client(hass.http.app) + await client.post('/api/webhook/{}'.format(webhook_id), data={ + 'hello': 'twilio' }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result - result = await hass.config_entries.flow.async_configure( - result['flow_id'], {}) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - webhook_id = result['result'].data['webhook_id'] - - twilio_events = [] - - @callback - def handle_event(event): - """Handle Twilio event.""" - twilio_events.append(event) - - hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event) - - client = await aiohttp_client(hass.http.app) - await client.post('/api/webhook/{}'.format(webhook_id), data={ - 'hello': 'twilio' - }) - - assert len(twilio_events) == 1 - assert twilio_events[0].data['webhook_id'] == webhook_id - assert twilio_events[0].data['hello'] == 'twilio' + assert len(twilio_events) == 1 + assert twilio_events[0].data['webhook_id'] == webhook_id + assert twilio_events[0].data['hello'] == 'twilio' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index c2634b2d621..d4077345649 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -23,36 +23,34 @@ from tests.common import ( from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues -@asyncio.coroutine -def test_valid_device_config(hass, mock_openzwave): +async def test_valid_device_config(hass, mock_openzwave): """Test valid device config.""" device_config = { 'light.kitchen': { 'ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result -@asyncio.coroutine -def test_invalid_device_config(hass, mock_openzwave): +async def test_invalid_device_config(hass, mock_openzwave): """Test invalid device config.""" device_config = { 'light.kitchen': { 'some_ignored': 'true' } } - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'device_config': device_config }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not result @@ -69,15 +67,14 @@ def test_config_access_error(): assert result is None -@asyncio.coroutine -def test_network_options(hass, mock_openzwave): +async def test_network_options(hass, mock_openzwave): """Test network options.""" - result = yield from async_setup_component(hass, 'zwave', { + result = await async_setup_component(hass, 'zwave', { 'zwave': { 'usb_path': 'mock_usb_path', 'config_path': 'mock_config_path', }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert result @@ -86,62 +83,59 @@ def test_network_options(hass, mock_openzwave): assert network.options.config_path == 'mock_config_path' -@asyncio.coroutine -def test_auto_heal_midnight(hass, mock_openzwave): +async def test_auto_heal_midnight(hass, mock_openzwave): """Test network auto-heal at midnight.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': True, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert network.heal.called assert len(network.heal.mock_calls) == 1 -@asyncio.coroutine -def test_auto_heal_disabled(hass, mock_openzwave): +async def test_auto_heal_disabled(hass, mock_openzwave): """Test network auto-heal disabled.""" - yield from async_setup_component(hass, 'zwave', { + await async_setup_component(hass, 'zwave', { 'zwave': { 'autoheal': False, }}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() network = hass.data[zwave.DATA_NETWORK] assert not network.heal.called time = utc.localize(datetime(2017, 5, 6, 0, 0, 0)) async_fire_time_changed(hass, time) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not network.heal.called -@asyncio.coroutine -def test_setup_platform(hass, mock_openzwave): +async def test_setup_platform(hass, mock_openzwave): """Test invalid device config.""" mock_device = MagicMock() hass.data[DATA_NETWORK] = MagicMock() hass.data[zwave.DATA_DEVICES] = {456: mock_device} async_add_entities = MagicMock() - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, None) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 123}) assert not result assert not async_add_entities.called - result = yield from zwave.async_setup_platform( + result = await zwave.async_setup_platform( hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 456}) assert result assert async_add_entities.called @@ -149,12 +143,11 @@ def test_setup_platform(hass, mock_openzwave): assert async_add_entities.mock_calls[0][1][0] == [mock_device] -@asyncio.coroutine -def test_zwave_ready_wait(hass, mock_openzwave): +async def test_zwave_ready_wait(hass, mock_openzwave): """Test that zwave continues after waiting for network ready.""" # Initialize zwave - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() sleeps = [] @@ -163,18 +156,17 @@ def test_zwave_ready_wait(hass, mock_openzwave): asyncio_sleep = asyncio.sleep - @asyncio.coroutine - def sleep(duration, loop=None): + async def sleep(duration, loop=None): if duration > 0: sleeps.append(duration) - yield from asyncio_sleep(0) + await asyncio_sleep(0) with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): with patch('asyncio.sleep', new=sleep): with patch.object(zwave, '_LOGGER') as mock_logger: hass.data[DATA_NETWORK].state = MockNetwork.STATE_STARTED hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(sleeps) == const.NETWORK_READY_WAIT_SECS assert mock_logger.warning.called @@ -183,8 +175,7 @@ def test_zwave_ready_wait(hass, mock_openzwave): const.NETWORK_READY_WAIT_SECS -@asyncio.coroutine -def test_device_entity(hass, mock_openzwave): +async def test_device_entity(hass, mock_openzwave): """Test device entity base class.""" node = MockNode(node_id='10', name='Mock Node') value = MockValue(data=False, node=node, instance=2, object_id='11', @@ -197,7 +188,7 @@ def test_device_entity(hass, mock_openzwave): device.hass = hass device.value_added() device.update_properties() - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert not device.should_poll assert device.unique_id == "10-11" @@ -205,8 +196,7 @@ def test_device_entity(hass, mock_openzwave): assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123 -@asyncio.coroutine -def test_node_discovery(hass, mock_openzwave): +async def test_node_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -215,14 +205,14 @@ def test_node_discovery(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node').state is 'unknown' @@ -270,8 +260,7 @@ async def test_unparsed_node_discovery(hass, mock_openzwave): assert hass.states.get('zwave.unknown_node_14').state is 'unknown' -@asyncio.coroutine -def test_node_ignored(hass, mock_openzwave): +async def test_node_ignored(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -280,24 +269,23 @@ def test_node_ignored(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': { + await async_setup_component(hass, 'zwave', {'zwave': { 'device_config': { 'zwave.mock_node': { 'ignored': True, }}}}) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(mock_receivers) == 1 node = MockNode(node_id=14) hass.async_add_job(mock_receivers[0], node) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('zwave.mock_node') is None -@asyncio.coroutine -def test_value_discovery(hass, mock_openzwave): +async def test_value_discovery(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -306,8 +294,8 @@ def test_value_discovery(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -316,14 +304,13 @@ def test_value_discovery(hass, mock_openzwave): command_class=const.COMMAND_CLASS_SENSOR_BINARY, type=const.TYPE_BOOL, genre=const.GENRE_USER) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get( 'binary_sensor.mock_node_mock_value').state is 'off' -@asyncio.coroutine -def test_value_discovery_existing_entity(hass, mock_openzwave): +async def test_value_discovery_existing_entity(hass, mock_openzwave): """Test discovery of a node.""" mock_receivers = [] @@ -332,8 +319,8 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -343,7 +330,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): command_class=const.COMMAND_CLASS_THERMOSTAT_SETPOINT, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, setpoint) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -360,7 +347,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL, genre=const.GENRE_USER, units='C') hass.async_add_job(mock_receivers[0], node, temperature) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('climate.mock_node_mock_value').attributes[ 'temperature'] == 22.0 @@ -368,8 +355,7 @@ def test_value_discovery_existing_entity(hass, mock_openzwave): 'current_temperature'] == 23.5 -@asyncio.coroutine -def test_power_schemes(hass, mock_openzwave): +async def test_power_schemes(hass, mock_openzwave): """Test power attribute.""" mock_receivers = [] @@ -378,8 +364,8 @@ def test_power_schemes(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -390,7 +376,7 @@ def test_power_schemes(hass, mock_openzwave): genre=const.GENRE_USER, type=const.TYPE_BOOL) hass.async_add_job(mock_receivers[0], node, switch) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').state == 'on' assert 'power_consumption' not in hass.states.get( @@ -405,14 +391,13 @@ def test_power_schemes(hass, mock_openzwave): data=23.5, node=node, index=const.INDEX_SENSOR_MULTILEVEL_POWER, instance=13, command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL) hass.async_add_job(mock_receivers[0], node, power) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert hass.states.get('switch.mock_node_mock_value').attributes[ 'power_consumption'] == 23.5 -@asyncio.coroutine -def test_network_ready(hass, mock_openzwave): +async def test_network_ready(hass, mock_openzwave): """Test Node network ready event.""" mock_receivers = [] @@ -421,8 +406,8 @@ def test_network_ready(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -434,13 +419,12 @@ def test_network_ready(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete(hass, mock_openzwave): +async def test_network_complete(hass, mock_openzwave): """Test Node network complete event.""" mock_receivers = [] @@ -449,8 +433,8 @@ def test_network_complete(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -462,13 +446,12 @@ def test_network_complete(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_READY, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 -@asyncio.coroutine -def test_network_complete_some_dead(hass, mock_openzwave): +async def test_network_complete_some_dead(hass, mock_openzwave): """Test Node network complete some dead event.""" mock_receivers = [] @@ -477,8 +460,8 @@ def test_network_complete_some_dead(hass, mock_openzwave): mock_receivers.append(receiver) with patch('pydispatch.dispatcher.connect', new=mock_connect): - yield from async_setup_component(hass, 'zwave', {'zwave': {}}) - yield from hass.async_block_till_done() + await async_setup_component(hass, 'zwave', {'zwave': {}}) + await hass.async_block_till_done() assert len(mock_receivers) == 1 @@ -490,7 +473,7 @@ def test_network_complete_some_dead(hass, mock_openzwave): hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE_SOME_DEAD, listener) hass.async_add_job(mock_receivers[0]) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 034360c6b3e..b8f88e6f37f 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -1,5 +1,4 @@ """Test Z-Wave node entity.""" -import asyncio import unittest from unittest.mock import patch, MagicMock import tests.mock.zwave as mock_zwave @@ -8,8 +7,7 @@ from homeassistant.components.zwave import node_entity, const from homeassistant.const import ATTR_ENTITY_ID -@asyncio.coroutine -def test_maybe_schedule_update(hass, mock_openzwave): +async def test_maybe_schedule_update(hass, mock_openzwave): """Test maybe schedule update.""" base_entity = node_entity.ZWaveBaseEntity() base_entity.hass = hass @@ -31,8 +29,7 @@ def test_maybe_schedule_update(hass, mock_openzwave): assert len(mock_call_later.mock_calls) == 2 -@asyncio.coroutine -def test_node_event_activated(hass, mock_openzwave): +async def test_node_event_activated(hass, mock_openzwave): """Test Node event activated event.""" mock_receivers = [] @@ -57,7 +54,7 @@ def test_node_event_activated(hass, mock_openzwave): # Test event before entity added to hass value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -66,7 +63,7 @@ def test_node_event_activated(hass, mock_openzwave): value = 234 hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -74,8 +71,7 @@ def test_node_event_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_BASIC_LEVEL] == value -@asyncio.coroutine -def test_scene_activated(hass, mock_openzwave): +async def test_scene_activated(hass, mock_openzwave): """Test scene activated event.""" mock_receivers = [] @@ -100,7 +96,7 @@ def test_scene_activated(hass, mock_openzwave): # Test event before entity added to hass scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -109,7 +105,7 @@ def test_scene_activated(hass, mock_openzwave): scene_id = 123 hass.async_add_job(mock_receivers[0], node, scene_id) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" @@ -117,8 +113,7 @@ def test_scene_activated(hass, mock_openzwave): assert events[0].data[const.ATTR_SCENE_ID] == scene_id -@asyncio.coroutine -def test_central_scene_activated(hass, mock_openzwave): +async def test_central_scene_activated(hass, mock_openzwave): """Test central scene activated event.""" mock_receivers = [] @@ -148,7 +143,7 @@ def test_central_scene_activated(hass, mock_openzwave): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 0 # Add entity to hass @@ -162,7 +157,7 @@ def test_central_scene_activated(hass, mock_openzwave): index=scene_id, data=scene_data) hass.async_add_job(mock_receivers[0], node, value) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert len(events) == 1 assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node" diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 2f203ceb963..59bcab92b1e 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -17,7 +17,9 @@ async def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, sw_version='sw-version', name='name', @@ -25,12 +27,16 @@ async def test_get_or_create_returns_same_entry(registry): model='model') entry2 = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '11:22:33:44:55:66:77:88')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '11:22:33:66:77:88') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')} + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + } ) assert len(registry.devices) == 1 @@ -48,7 +54,9 @@ async def test_requirement_for_identifier_or_connection(registry): """Make sure we do require some descriptor of device.""" entry = registry.async_get_or_create( config_entry_id='1234', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers=set(), manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( @@ -72,17 +80,23 @@ async def test_multiple_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( config_entry_id='456', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') @@ -112,7 +126,7 @@ async def test_loading_from_storage(hass, hass_storage): 'identifiers': [ [ 'serial', - '12:34:56:78:90:AB:CD:EF' + '12:34:56:AB:CD:EF' ] ], 'manufacturer': 'manufacturer', @@ -129,7 +143,7 @@ async def test_loading_from_storage(hass, hass_storage): entry = registry.async_get_or_create( config_entry_id='1234', connections={('Zigbee', '01.23.45.67.89')}, - identifiers={('serial', '12:34:56:78:90:AB:CD:EF')}, + identifiers={('serial', '12:34:56:AB:CD:EF')}, manufacturer='manufacturer', model='model') assert entry.id == 'abcdefghijklm' assert isinstance(entry.config_entries, set) @@ -139,17 +153,23 @@ async def test_removing_config_entries(registry): """Make sure we do not get duplicate entries.""" entry = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry2 = registry.async_get_or_create( config_entry_id='456', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('bridgeid', '0123')}, manufacturer='manufacturer', model='model') entry3 = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '34:56:78:90:AB:CD:EF:12')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '34:56:78:CD:EF:12') + }, identifiers={('bridgeid', '4567')}, manufacturer='manufacturer', model='model') @@ -170,7 +190,9 @@ async def test_specifying_hub_device_create(registry): """Test specifying a hub and updating.""" hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -197,7 +219,9 @@ async def test_specifying_hub_device_update(registry): hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -215,7 +239,9 @@ async def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" orig_hub = registry.async_get_or_create( config_entry_id='123', - connections={('ethernet', '12:34:56:78:90:AB:CD:EF')}, + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, identifiers={('hue', '0123')}, manufacturer='manufacturer', model='hub') @@ -259,3 +285,46 @@ async def test_no_unnecessary_changes(registry): assert entry.id == entry2.id assert len(mock_save.mock_calls) == 0 + + +async def test_format_mac(registry): + """Make sure we normalize mac addresses.""" + entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }, + ) + for mac in [ + '123456ABCDEF', + '123456abcdef', + '12:34:56:ab:cd:ef', + '1234.56ab.cdef', + ]: + test_entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, mac) + }, + ) + assert test_entry.id == entry.id, mac + assert test_entry.connections == { + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:ab:cd:ef') + } + + # This should not raise + for invalid in [ + 'invalid_mac', + '123456ABCDEFG', # 1 extra char + '12:34:56:ab:cdef', # not enough : + '12:34:56:ab:cd:e:f', # too many : + '1234.56abcdef', # not enough . + '123.456.abc.def', # too many . + ]: + invalid_mac_entry = registry.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, invalid) + }, + ) + assert list(invalid_mac_entry.connections)[0][1] == invalid diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index d217b99b3a8..e5e62d2aed3 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -167,6 +167,7 @@ class TestScriptHelper(unittest.TestCase): event = 'test_event' events = [] context = Context() + delay_alias = 'delay step' @callback def record_event(event): @@ -177,7 +178,7 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'delay': {'seconds': 5}}, + {'delay': {'seconds': 5}, 'alias': delay_alias}, {'event': event}])) script_obj.run(context=context) @@ -185,7 +186,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -201,6 +202,7 @@ class TestScriptHelper(unittest.TestCase): """Test the delay as a template.""" event = 'test_event' events = [] + delay_alias = 'delay step' @callback def record_event(event): @@ -211,7 +213,7 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'delay': '00:00:{{ 5 }}'}, + {'delay': '00:00:{{ 5 }}', 'alias': delay_alias}, {'event': event}])) script_obj.run() @@ -219,7 +221,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -259,6 +261,7 @@ class TestScriptHelper(unittest.TestCase): """Test the delay with a working complex template.""" event = 'test_event' events = [] + delay_alias = 'delay step' @callback def record_event(event): @@ -270,8 +273,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, {'delay': { - 'seconds': '{{ 5 }}' - }}, + 'seconds': '{{ 5 }}'}, + 'alias': delay_alias}, {'event': event}])) script_obj.run() @@ -279,7 +282,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == delay_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -358,6 +361,7 @@ class TestScriptHelper(unittest.TestCase): event = 'test_event' events = [] context = Context() + wait_alias = 'wait step' @callback def record_event(event): @@ -370,7 +374,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{states.switch.test.state == 'off'}}"}, + {'wait_template': "{{states.switch.test.state == 'off'}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run(context=context) @@ -378,7 +383,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 self.hass.states.set('switch.test', 'off') @@ -393,6 +398,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template cancel action.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -405,7 +411,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{states.switch.test.state == 'off'}}"}, + {'wait_template': "{{states.switch.test.state == 'off'}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run() @@ -413,7 +420,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 script_obj.stop() @@ -457,6 +464,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template, halt on timeout.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -472,7 +480,8 @@ class TestScriptHelper(unittest.TestCase): { 'wait_template': "{{states.switch.test.state == 'off'}}", 'continue_on_timeout': False, - 'timeout': 5 + 'timeout': 5, + 'alias': wait_alias }, {'event': event}])) @@ -481,7 +490,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -495,6 +504,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with continuing the script.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -510,7 +520,8 @@ class TestScriptHelper(unittest.TestCase): { 'wait_template': "{{states.switch.test.state == 'off'}}", 'timeout': 5, - 'continue_on_timeout': True + 'continue_on_timeout': True, + 'alias': wait_alias }, {'event': event}])) @@ -519,7 +530,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -533,6 +544,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with default contiune.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -547,7 +559,8 @@ class TestScriptHelper(unittest.TestCase): {'event': event}, { 'wait_template': "{{states.switch.test.state == 'off'}}", - 'timeout': 5 + 'timeout': 5, + 'alias': wait_alias }, {'event': event}])) @@ -556,7 +569,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 future = dt_util.utcnow() + timedelta(seconds=5) @@ -570,6 +583,7 @@ class TestScriptHelper(unittest.TestCase): """Test the wait template with variables.""" event = 'test_event' events = [] + wait_alias = 'wait step' @callback def record_event(event): @@ -582,7 +596,8 @@ class TestScriptHelper(unittest.TestCase): script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([ {'event': event}, - {'wait_template': "{{is_state(data, 'off')}}"}, + {'wait_template': "{{is_state(data, 'off')}}", + 'alias': wait_alias}, {'event': event}])) script_obj.run({ @@ -592,7 +607,7 @@ class TestScriptHelper(unittest.TestCase): assert script_obj.is_running assert script_obj.can_cancel - assert script_obj.last_action == event + assert script_obj.last_action == wait_alias assert len(events) == 1 self.hass.states.set('switch.test', 'off') diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 71775574c28..a4e9a571943 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,18 +1,49 @@ """Test service helpers.""" import asyncio +from collections import OrderedDict from copy import deepcopy import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest # To prevent circular import when running just this file import homeassistant.components # noqa -from homeassistant import core as ha, loader +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 +from tests.common import get_test_home_assistant, mock_service, mock_coro + + +@pytest.fixture +def mock_service_platform_call(): + """Mock service platform call.""" + with patch('homeassistant.helpers.service._handle_service_platform_call', + side_effect=lambda *args: mock_coro()) as mock_call: + yield mock_call + + +@pytest.fixture +def mock_entities(): + """Return mock entities in an ordered dict.""" + kitchen = Mock( + entity_id='light.kitchen', + available=True, + should_poll=False, + ) + living_room = Mock( + entity_id='light.living_room', + available=True, + should_poll=False, + ) + entities = OrderedDict() + entities[kitchen.entity_id] = kitchen + entities[living_room.entity_id] = living_room + return entities class TestServiceHelpers(unittest.TestCase): @@ -179,3 +210,99 @@ def test_async_get_all_descriptions(hass): assert 'description' in descriptions[logger.DOMAIN]['set_level'] assert 'fields' in descriptions[logger.DOMAIN]['set_level'] + + +async def test_call_context_user_not_exist(hass): + """Check we don't allow deleted users to do things.""" + with pytest.raises(exceptions.UnknownUser) as err: + await service.entity_service_call(hass, [], Mock(), ha.ServiceCall( + 'test_domain', 'test_service', context=ha.Context( + user_id='non-existing'))) + + assert err.value.context.user_id == 'non-existing' + + +async def test_call_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we only target allowed entities if targetting all.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', + context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific(hass, mock_service_platform_call, + mock_entities): + """Check targeting specific entities.""" + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock(permissions=PolicyPermissions({ + 'entities': { + 'entity_ids': { + 'light.kitchen': True + } + } + })))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] + + +async def test_call_context_target_specific_no_auth( + hass, mock_service_platform_call, mock_entities): + """Check targeting specific entities without auth.""" + with pytest.raises(exceptions.Unauthorized) as err: + with patch('homeassistant.auth.AuthManager.async_get_user', + return_value=mock_coro(Mock( + permissions=PolicyPermissions({})))): + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': 'light.kitchen' + }, context=ha.Context(user_id='mock-id'))) + + assert err.value.context.user_id == 'mock-id' + assert err.value.entity_id == 'light.kitchen' + + +async def test_call_no_context_target_all(hass, mock_service_platform_call, + mock_entities): + """Check we target all if no user context given.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service')) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == list(mock_entities.values()) + + +async def test_call_no_context_target_specific( + hass, mock_service_platform_call, mock_entities): + """Check we can target specified entities.""" + await service.entity_service_call(hass, [ + Mock(entities=mock_entities) + ], Mock(), ha.ServiceCall('test_domain', 'test_service', { + 'entity_id': ['light.kitchen', 'light.non-existing'] + })) + + assert len(mock_service_platform_call.mock_calls) == 1 + entities = mock_service_platform_call.mock_calls[0][1][2] + assert entities == [mock_entities['light.kitchen']] diff --git a/tests/test_setup.py b/tests/test_setup.py index 29712f40ebc..2e44ee539d7 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -9,7 +9,8 @@ import logging import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED) import homeassistant.config as config_util from homeassistant import setup, loader import homeassistant.util.dt as dt_util @@ -459,3 +460,35 @@ def test_platform_no_warn_slow(hass): hass, 'test_component1', {}) assert result assert not mock_call.called + + +async def test_when_setup_already_loaded(hass): + """Test when setup.""" + calls = [] + + async def mock_callback(hass, component): + """Mock callback.""" + calls.append(component) + + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == [] + + hass.config.components.add('test') + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Event listener should be gone + hass.bus.async_fire(EVENT_COMPONENT_LOADED, { + 'component': 'test' + }) + await hass.async_block_till_done() + assert calls == ['test'] + + # Should be called right away + setup.async_when_setup(hass, 'test', mock_callback) + await hass.async_block_till_done() + assert calls == ['test', 'test'] diff --git a/tox.ini b/tox.ini index 4a44feb6c7f..1ab771ff24b 100644 --- a/tox.ini +++ b/tox.ini @@ -60,4 +60,4 @@ whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{icon,intent,json,location,state,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py'