diff --git a/.coveragerc b/.coveragerc index 2a6446092e5667..8d98a0c23e0f42 100644 --- a/.coveragerc +++ b/.coveragerc @@ -148,6 +148,9 @@ omit = homeassistant/components/hive.py homeassistant/components/*/hive.py + homeassistant/components/hlk_sw16.py + homeassistant/components/*/hlk_sw16.py + homeassistant/components/homekit_controller/__init__.py homeassistant/components/*/homekit_controller.py @@ -203,6 +206,9 @@ omit = homeassistant/components/linode.py homeassistant/components/*/linode.py + homeassistant/components/lightwave.py + homeassistant/components/*/lightwave.py + homeassistant/components/logi_circle.py homeassistant/components/*/logi_circle.py @@ -323,7 +329,8 @@ omit = homeassistant/components/tahoma.py homeassistant/components/*/tahoma.py - homeassistant/components/tellduslive.py + homeassistant/components/tellduslive/__init__.py + homeassistant/components/tellduslive/entry.py homeassistant/components/*/tellduslive.py homeassistant/components/tellstick.py @@ -400,6 +407,8 @@ omit = homeassistant/components/zha/__init__.py homeassistant/components/zha/const.py + homeassistant/components/zha/entities/* + homeassistant/components/zha/helpers.py homeassistant/components/*/zha.py homeassistant/components/zigbee.py @@ -637,7 +646,6 @@ omit = homeassistant/components/notify/group.py homeassistant/components/notify/hipchat.py homeassistant/components/notify/homematic.py - homeassistant/components/notify/instapush.py homeassistant/components/notify/kodi.py homeassistant/components/notify/lannouncer.py homeassistant/components/notify/llamalab_automate.py @@ -780,6 +788,7 @@ omit = homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/pyload.py + homeassistant/components/sensor/qbittorrent.py homeassistant/components/sensor/qnap.py homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py diff --git a/CODEOWNERS b/CODEOWNERS index dabc3bbd4db9df..659f434d14b271 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/binary_sensor/threshold.py @fabaff +homeassistant/components/binary_sensor/uptimerobot.py @ludeeus homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/ephember.py @ttroy50 homeassistant/components/climate/eq3btsmart.py @rytilahti @@ -61,9 +62,11 @@ homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/device_tracker/googlehome.py @ludeeus homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan homeassistant/components/device_tracker/tile.py @bachya +homeassistant/components/device_tracker/traccar.py @ludeeus homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/history_graph.py @andrey-git homeassistant/components/influx.py @fabaff @@ -109,6 +112,7 @@ homeassistant/components/sensor/glances.py @fabaff homeassistant/components/sensor/gpsd.py @fabaff homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/jewish_calendar.py @tsvi +homeassistant/components/sensor/launch_library.py @ludeeus homeassistant/components/sensor/linux_battery.py @fabaff homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff @@ -119,6 +123,7 @@ homeassistant/components/sensor/pi_hole.py @fabaff homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/pvoutput.py @fabaff homeassistant/components/sensor/qnap.py @colinodell +homeassistant/components/sensor/ruter.py @ludeeus homeassistant/components/sensor/scrape.py @fabaff homeassistant/components/sensor/serial.py @fabaff homeassistant/components/sensor/seventeentrack.py @bachya @@ -128,12 +133,15 @@ homeassistant/components/sensor/sql.py @dgomes homeassistant/components/sensor/statistics.py @fabaff homeassistant/components/sensor/swiss*.py @fabaff homeassistant/components/sensor/sytadin.py @gautric +homeassistant/components/sensor/tautulli.py @ludeeus homeassistant/components/sensor/time_data.py @fabaff homeassistant/components/sensor/version.py @fabaff homeassistant/components/sensor/waqi.py @andrey-git homeassistant/components/sensor/worldclock.py @fabaff homeassistant/components/shiftr.py @fabaff homeassistant/components/spaceapi.py @fabaff +homeassistant/components/switch/switchbot.py @danielhiversen +homeassistant/components/switch/switchmate.py @danielhiversen homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/vacuum/roomba.py @pschmitt homeassistant/components/weather/__init__.py @fabaff @@ -157,9 +165,12 @@ homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel homeassistant/components/*/broadlink.py @danielhiversen # C +homeassistant/components/cloudflare.py @ludeeus homeassistant/components/counter/* @fabaff # D +homeassistant/components/daikin.py @fredrike @rofrantz +homeassistant/components/*/daikin.py @fredrike @rofrantz homeassistant/components/*/deconz.py @kane610 homeassistant/components/digital_ocean.py @fabaff homeassistant/components/*/digital_ocean.py @fabaff @@ -204,6 +215,10 @@ homeassistant/components/*/mystrom.py @fabaff homeassistant/components/openuv/* @bachya homeassistant/components/*/openuv.py @bachya +# P +homeassistant/components/point/* @fredrike +homeassistant/components/*/point.py @fredrike + # Q homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza @@ -221,8 +236,8 @@ homeassistant/components/*/simplisafe.py @bachya # T homeassistant/components/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei -homeassistant/components/tellduslive.py @molobrakos @fredrike -homeassistant/components/*/tellduslive.py @molobrakos @fredrike +homeassistant/components/tellduslive/*.py @fredrike +homeassistant/components/*/tellduslive.py @fredrike homeassistant/components/tesla.py @zabuldon homeassistant/components/*/tesla.py @zabuldon homeassistant/components/thethingsnetwork.py @fabaff diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 49f01211e5a208..3377bb2a6aa0d5 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -78,11 +78,6 @@ def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore, hass, self._async_create_login_flow, self._async_finish_login_flow) - @property - def active(self) -> bool: - """Return if any auth providers are registered.""" - return bool(self._providers) - @property def support_legacy(self) -> bool: """ diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index bad1bdcf913e61..c6078e03f63a78 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,4 +1,5 @@ """Storage for auth models.""" +import asyncio from collections import OrderedDict from datetime import timedelta import hmac @@ -11,7 +12,7 @@ from . import models from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY -from .permissions import system_policies +from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 @@ -34,6 +35,7 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self._users = None # type: Optional[Dict[str, models.User]] self._groups = None # type: Optional[Dict[str, models.Group]] + self._perm_lookup = None # type: Optional[PermissionLookup] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) @@ -94,6 +96,7 @@ async def async_create_user( # Until we get group management, we just put everyone in the # same group. 'groups': groups, + 'perm_lookup': self._perm_lookup, } # type: Dict[str, Any] if is_owner is not None: @@ -269,13 +272,18 @@ def async_log_refresh_token_usage( async def _async_load(self) -> None: """Load the users.""" - data = await self._store.async_load() + [ent_reg, data] = await asyncio.gather( + self.hass.helpers.entity_registry.async_get_registry(), + self._store.async_load(), + ) # Make sure that we're not overriding data if 2 loads happened at the # same time if self._users is not None: return + self._perm_lookup = perm_lookup = PermissionLookup(ent_reg) + if data is None: self._set_defaults() return @@ -374,6 +382,7 @@ async def _async_load(self) -> None: is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], + perm_lookup=perm_lookup, ) for cred_dict in data['credentials']: diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 03be4c74d32bfe..3c26f8b4bde446 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -4,13 +4,14 @@ """ import logging from collections import OrderedDict -from typing import Any, Dict, Optional, Tuple, List # noqa: F401 +from typing import Any, Dict, Optional, List import attr import voluptuous as vol from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceNotFound from homeassistant.helpers import config_validation as cv from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \ @@ -314,8 +315,11 @@ async def async_step_setup( _generate_otp, self._secret, self._count) assert self._notify_service - await self._auth_module.async_notify( - code, self._notify_service, self._target) + try: + await self._auth_module.async_notify( + code, self._notify_service, self._target) + except ServiceNotFound: + return self.async_abort(reason='notify_service_not_exist') return self.async_show_form( step_id='setup', diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 4b192c35898e13..588d80047bedd1 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -31,6 +31,9 @@ class User: """A user.""" name = attr.ib(type=str) # type: Optional[str] + perm_lookup = attr.ib( + type=perm_mdl.PermissionLookup, cmp=False, + ) # type: perm_mdl.PermissionLookup id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) @@ -66,7 +69,8 @@ def permissions(self) -> perm_mdl.AbstractPermissions: self._permissions = perm_mdl.PolicyPermissions( perm_mdl.merge_policies([ - group.policy for group in self.groups])) + group.policy for group in self.groups]), + self.perm_lookup) return self._permissions diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 9113f2b03a9562..63e76dd2496906 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,15 +1,18 @@ """Permissions for Home Assistant.""" import logging from typing import ( # noqa: F401 - cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union) + cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union, + TYPE_CHECKING) import voluptuous as vol from .const import CAT_ENTITIES +from .models import PermissionLookup from .types import PolicyType from .entities import ENTITY_POLICY_SCHEMA, compile_entities from .merge import merge_policies # noqa + POLICY_SCHEMA = vol.Schema({ vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA }) @@ -39,13 +42,16 @@ def check_entity(self, entity_id: str, key: str) -> bool: class PolicyPermissions(AbstractPermissions): """Handle permissions.""" - def __init__(self, policy: PolicyType) -> None: + def __init__(self, policy: PolicyType, + perm_lookup: PermissionLookup) -> None: """Initialize the permission class.""" self._policy = policy + self._perm_lookup = perm_lookup def _entity_func(self) -> Callable[[str, str], bool]: """Return a function that can test entity access.""" - return compile_entities(self._policy.get(CAT_ENTITIES)) + return compile_entities(self._policy.get(CAT_ENTITIES), + self._perm_lookup) def __eq__(self, other: Any) -> bool: """Equals check.""" diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 74a43246fd1797..0073c9526488e2 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,11 +1,11 @@ """Entity permissions.""" from functools import wraps -from typing import ( # noqa: F401 - Callable, Dict, List, Tuple, Union) +from typing import Callable, List, Union # noqa: F401 import voluptuous as vol from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT +from .models import PermissionLookup from .types import CategoryType, ValueType SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ @@ -15,6 +15,7 @@ })) ENTITY_DOMAINS = 'domains' +ENTITY_DEVICE_IDS = 'device_ids' ENTITY_ENTITY_IDS = 'entity_ids' ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({ @@ -23,6 +24,7 @@ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, })) @@ -37,7 +39,7 @@ def _entity_allowed(schema: ValueType, key: str) \ return schema.get(key) -def compile_entities(policy: CategoryType) \ +def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \ -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" # None, Empty Dict, False @@ -58,6 +60,7 @@ def apply_policy_allow_all(entity_id: str, key: str) -> bool: assert isinstance(policy, dict) domains = policy.get(ENTITY_DOMAINS) + device_ids = policy.get(ENTITY_DEVICE_IDS) entity_ids = policy.get(ENTITY_ENTITY_IDS) all_entities = policy.get(SUBCAT_ALL) @@ -85,6 +88,29 @@ def allowed_entity_id_dict(entity_id: str, key: str) \ funcs.append(allowed_entity_id_dict) + if isinstance(device_ids, bool): + def allowed_device_id_bool(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + return device_ids + + funcs.append(allowed_device_id_bool) + + elif device_ids is not None: + def allowed_device_id_dict(entity_id: str, key: str) \ + -> Union[None, bool]: + """Test if allowed device_id.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return _entity_allowed( + device_ids.get(entity_entry.device_id), key # type: ignore + ) + + funcs.append(allowed_device_id_dict) + if isinstance(domains, bool): def allowed_domain_bool(entity_id: str, key: str) \ -> Union[None, bool]: diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py new file mode 100644 index 00000000000000..7ad7d5521c5f97 --- /dev/null +++ b/homeassistant/auth/permissions/models.py @@ -0,0 +1,17 @@ +"""Models for permissions.""" +from typing import TYPE_CHECKING + +import attr + +if TYPE_CHECKING: + # pylint: disable=unused-import + from homeassistant.helpers import ( # noqa + entity_registry as ent_reg, + ) + + +@attr.s(slots=True) +class PermissionLookup: + """Class to hold data for permission lookups.""" + + entity_registry = attr.ib(type='ent_reg.EntityRegistry') diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 1871861f29102d..78d13b9679f74f 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -1,6 +1,5 @@ """Common code for permissions.""" -from typing import ( # noqa: F401 - Mapping, Union, Any) +from typing import Mapping, Union # MyPy doesn't support recursion yet. So writing it out as far as we need. diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 9ca4232b6106e2..8828782c886e96 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -226,7 +226,11 @@ async def async_step_mfa( if user_input is None and hasattr(auth_module, 'async_initialize_login_mfa_step'): - await auth_module.async_initialize_login_mfa_step(self.user.id) + try: + await auth_module.async_initialize_login_mfa_step(self.user.id) + except HomeAssistantError: + _LOGGER.exception('Error initializing MFA step') + return self.async_abort(reason='unknown_error') if user_input is not None: expires = self.created_at + MFA_SESSION_EXPIRATION diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 8710e7c60bc090..2c5a76d2c90612 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,8 +1,6 @@ """Home Assistant auth provider.""" import base64 from collections import OrderedDict -import hashlib -import hmac from typing import Any, Dict, List, Optional, cast import bcrypt @@ -11,12 +9,10 @@ from homeassistant.const import CONF_ID from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.async_ import run_coroutine_threadsafe from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow from ..models import Credentials, UserMeta -from ..util import generate_secret STORAGE_VERSION = 1 @@ -62,7 +58,6 @@ async def async_load(self) -> None: if data is None: data = { - 'salt': generate_secret(), 'users': [] } @@ -94,39 +89,11 @@ def validate_login(self, username: str, password: str) -> None: user_hash = base64.b64decode(found['password']) - # if the hash is not a bcrypt hash... - # provide a transparant upgrade for old pbkdf2 hash format - if not (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')): - # IMPORTANT! validate the login, bail if invalid - hashed = self.legacy_hash_password(password) - if not hmac.compare_digest(hashed, user_hash): - raise InvalidAuth - # then re-hash the valid password with bcrypt - self.change_password(found['username'], password) - run_coroutine_threadsafe( - self.async_save(), self.hass.loop - ).result() - user_hash = base64.b64decode(found['password']) - # bcrypt.checkpw is timing-safe if not bcrypt.checkpw(password.encode(), user_hash): raise InvalidAuth - def legacy_hash_password(self, password: str, - for_storage: bool = False) -> bytes: - """LEGACY password encoding.""" - # We're no longer storing salts in data, but if one exists we - # should be able to retrieve it. - salt = self._data['salt'].encode() # type: ignore - hashed = hashlib.pbkdf2_hmac('sha512', password.encode(), salt, 100000) - if for_storage: - hashed = base64.b64encode(hashed) - return hashed - # pylint: disable=no-self-use def hash_password(self, password: str, for_storage: bool = False) -> bytes: """Encode a password.""" diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0676cec7fad5d4..c764bfe8c21b5d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -115,11 +115,6 @@ async def async_from_config_dict(config: Dict[str, Any], conf_util.merge_packages_config( hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) - # Ensure we have no None values after merge - for key, value in config.items(): - if not value: - config[key] = {} - hass.config_entries = config_entries.ConfigEntries(hass, config) await hass.config_entries.async_load() diff --git a/homeassistant/components/alarm_control_panel/blink.py b/homeassistant/components/alarm_control_panel/blink.py index 728b5967db1207..77267fd7516c8a 100644 --- a/homeassistant/components/alarm_control_panel/blink.py +++ b/homeassistant/components/alarm_control_panel/blink.py @@ -25,21 +25,19 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] - # Current version of blinkpy API only supports one sync module. When - # support for additional models is added, the sync module name should - # come from the API. sync_modules = [] - sync_modules.append(BlinkSyncModule(data, 'sync')) + for sync_name, sync_module in data.sync.items(): + sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) add_entities(sync_modules, True) class BlinkSyncModule(AlarmControlPanel): """Representation of a Blink Alarm Control Panel.""" - def __init__(self, data, name): + def __init__(self, data, name, sync): """Initialize the alarm control panel.""" self.data = data - self.sync = data.sync + self.sync = sync self._name = name self._state = None @@ -68,6 +66,7 @@ def device_state_attributes(self): """Return the state attributes.""" attr = self.sync.attributes attr['network_info'] = self.data.networks + attr['associated_cameras'] = list(self.sync.cameras.keys()) attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION return attr diff --git a/homeassistant/components/alarm_control_panel/lupusec.py b/homeassistant/components/alarm_control_panel/lupusec.py index 44d8a068ce22a4..21eefc238a051d 100644 --- a/homeassistant/components/alarm_control_panel/lupusec.py +++ b/homeassistant/components/alarm_control_panel/lupusec.py @@ -12,7 +12,8 @@ from homeassistant.components.lupusec import LupusecDevice from homeassistant.const import (STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) DEPENDENCIES = ['lupusec'] @@ -50,6 +51,8 @@ def state(self): state = STATE_ALARM_ARMED_AWAY elif self._device.is_home: state = STATE_ALARM_ARMED_HOME + elif self._device.is_alarm_triggered: + state = STATE_ALARM_TRIGGERED else: state = None return state diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 362923a4ce21d8..0a79d74d686077 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_point_in_time import homeassistant.util.dt as dt_util -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -116,7 +116,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): )]) -class ManualAlarm(alarm.AlarmControlPanel): +class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity): """ Representation of an alarm status. @@ -310,7 +310,7 @@ def device_state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state: self._state = state.state self._state_ts = state.last_updated diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index ad1c0d1e3b85e6..2a91ac77a8679e 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -19,7 +19,7 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate) + CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, 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 @@ -51,7 +51,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT alarm control panel through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -59,7 +59,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -67,54 +67,47 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_DISARM), - config.get(CONF_PAYLOAD_ARM_HOME), - config.get(CONF_PAYLOAD_ARM_AWAY), - config.get(CONF_CODE), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - discovery_hash,)]) + async_add_entities([MqttAlarm(config, discovery_hash)]) class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" - def __init__(self, name, state_topic, command_topic, qos, retain, - payload_disarm, payload_arm_home, payload_arm_away, code, - availability_topic, payload_available, payload_not_available, - discovery_hash): + def __init__(self, config, discovery_hash): """Init the MQTT Alarm Control Panel.""" + self._state = STATE_UNKNOWN + self._config = config + self._sub_state = None + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self._state = STATE_UNKNOWN - self._name = name - self._state_topic = state_topic - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_disarm = payload_disarm - self._payload_arm_home = payload_arm_home - self._payload_arm_away = payload_arm_away - self._code = code - self._discovery_hash = discovery_hash + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) 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 super().async_added_to_hass() + await self._subscribe_topics() + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" @callback def message_received(topic, payload, qos): """Run when new MQTT message has been received.""" @@ -126,8 +119,16 @@ def message_received(topic, payload, qos): self._state = payload self.async_schedule_update_ha_state() - await mqtt.async_subscribe( - self.hass, self._state_topic, message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': message_received, + 'qos': self._config.get(CONF_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): @@ -137,7 +138,7 @@ def should_poll(self): @property def name(self): """Return the name of the device.""" - return self._name + return self._config.get(CONF_NAME) @property def state(self): @@ -147,9 +148,10 @@ def state(self): @property def code_format(self): """Return one or more digits/characters.""" - if self._code is None: + code = self._config.get(CONF_CODE) + if code is None: return None - if isinstance(self._code, str) and re.search('^\\d+$', self._code): + if isinstance(code, str) and re.search('^\\d+$', code): return 'Number' return 'Any' @@ -161,8 +163,10 @@ async def async_alarm_disarm(self, code=None): if not self._validate_code(code, 'disarming'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_disarm, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_DISARM), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_alarm_arm_home(self, code=None): """Send arm home command. @@ -172,8 +176,10 @@ async def async_alarm_arm_home(self, code=None): if not self._validate_code(code, 'arming home'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_home, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ARM_HOME), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_alarm_arm_away(self, code=None): """Send arm away command. @@ -183,12 +189,15 @@ async def async_alarm_arm_away(self, code=None): if not self._validate_code(code, 'arming away'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_away, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ARM_AWAY), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) def _validate_code(self, code, state): """Validate given code.""" - check = self._code is None or code == self._code + conf_code = self._config.get(CONF_CODE) + check = conf_code is None or code == conf_code if not check: _LOGGER.warning('Wrong code entered for %s', state) return check diff --git a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py b/homeassistant/components/alarm_control_panel/yale_smart_alarm.py index e512d15fcdd701..357a8c350bc2c6 100755 --- a/homeassistant/components/alarm_control_panel/yale_smart_alarm.py +++ b/homeassistant/components/alarm_control_panel/yale_smart_alarm.py @@ -15,7 +15,7 @@ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['yalesmartalarmclient==0.1.4'] +REQUIREMENTS = ['yalesmartalarmclient==0.1.5'] CONF_AREA_ID = 'area_id' diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 2a61533a2b9f4c..f06b853087f147 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -504,6 +504,20 @@ class _AlexaColorTemperatureController(_AlexaInterface): def name(self): return 'Alexa.ColorTemperatureController' + def properties_supported(self): + return [{'name': 'colorTemperatureInKelvin'}] + + def properties_retrievable(self): + return True + + def get_property(self, name): + if name != 'colorTemperatureInKelvin': + raise _UnsupportedProperty(name) + if 'color_temp' in self.entity.attributes: + return color_util.color_temperature_mired_to_kelvin( + self.entity.attributes['color_temp']) + return 0 + class _AlexaPercentageController(_AlexaInterface): """Implements Alexa.PercentageController. diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index b001bcd0437258..961350bfa89771 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,7 +9,9 @@ import logging from aiohttp import web +from aiohttp.web_exceptions import HTTPBadRequest import async_timeout +import voluptuous as vol from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView @@ -21,7 +23,8 @@ URL_API_TEMPLATE, __version__) import homeassistant.core as ha from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import ( + TemplateError, Unauthorized, ServiceNotFound) from homeassistant.helpers import template from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.state import AsyncTrackStates @@ -339,8 +342,11 @@ async def post(self, request, domain, service): "Data should be valid JSON.", HTTP_BAD_REQUEST) with AsyncTrackStates(hass) as changed_states: - await hass.services.async_call( - domain, service, data, True, self.context(request)) + try: + await hass.services.async_call( + domain, service, data, True, self.context(request)) + except (vol.Invalid, ServiceNotFound): + raise HTTPBadRequest() return self.json(changed_states) diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index b8774d76873f2a..73cabdfbae615e 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.10'] +REQUIREMENTS = ['pyatv==0.3.12'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 1f12abd3d4e7ca..2073f680e00bd7 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,7 +11,6 @@ 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, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery @@ -141,11 +140,11 @@ def setup(hass, config): from requests import Session conf = config[DOMAIN] + api_http_session = None 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) @@ -157,6 +156,20 @@ def setup(hass, config): install_id=conf.get(CONF_INSTALL_ID), access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE)) + def close_http_session(event): + """Close API sessions used to connect to August.""" + _LOGGER.debug("Closing August HTTP sessions") + if api_http_session: + try: + api_http_session.close() + except RequestException: + pass + + _LOGGER.debug("August HTTP session closed.") + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) + _LOGGER.debug("Registered for HASS stop event") + return setup_august(hass, config, api, authenticator) @@ -178,22 +191,6 @@ def __init__(self, hass, api, access_token): 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.""" diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index f4318a0eb21cd8..236352a90183c9 100644 --- a/homeassistant/components/auth/.translations/ca.json +++ b/homeassistant/components/auth/.translations/ca.json @@ -13,7 +13,7 @@ "title": "Configureu una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions" }, "setup": { - "description": "**notify.{notify_service}** ha enviat una contrasenya d'un sol \u00fas. Introdu\u00efu-la a continuaci\u00f3:", + "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdu\u00efu-la a continuaci\u00f3:", "title": "Verifiqueu la configuraci\u00f3" } }, diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json index 508ffac67394ba..da234c3dd5dd57 100644 --- a/homeassistant/components/auth/.translations/cs.json +++ b/homeassistant/components/auth/.translations/cs.json @@ -13,6 +13,7 @@ "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify" }, "setup": { + "description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:", "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed" } } @@ -20,7 +21,14 @@ "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." - } + }, + "step": { + "init": { + "description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.", + "title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP" + } + }, + "title": "TOTP" } } } \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json index 2efc23f78f6715..223dc91a4800a3 100644 --- a/homeassistant/components/auth/.translations/sl.json +++ b/homeassistant/components/auth/.translations/sl.json @@ -2,18 +2,18 @@ "mfa_setup": { "notify": { "abort": { - "no_available_service": "Ni na voljo storitev obve\u0161\u010danja." + "no_available_service": "Storitve obve\u0161\u010danja niso na voljo." }, "error": { "invalid_code": "Neveljavna koda, poskusite znova." }, "step": { "init": { - "description": "Prosimo, izberite eno od storitev obve\u0161\u010danja:", + "description": "Izberite eno od storitev obve\u0161\u010danja:", "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento" }, "setup": { - "description": "Enkratno geslo je poslal **notify.{notify_service} **. Vnesite ga spodaj:", + "description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:", "title": "Preverite nastavitev" } }, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f8563071fbc523..4a2df399e0a209 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,12 +16,13 @@ from homeassistant.loader import bind_hass from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, - SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID) + SERVICE_TOGGLE, SERVICE_RELOAD, EVENT_HOMEASSISTANT_START, CONF_ID, + EVENT_AUTOMATION_TRIGGERED, ATTR_NAME) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import extract_domain_configs, script, condition from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util.dt import utcnow import homeassistant.helpers.config_validation as cv @@ -182,7 +183,7 @@ async def reload_service_handler(service_call): return True -class AutomationEntity(ToggleEntity): +class AutomationEntity(ToggleEntity, RestoreEntity): """Entity to show status of entity.""" def __init__(self, automation_id, name, async_attach_triggers, cond_func, @@ -227,12 +228,13 @@ def is_on(self) -> bool: async def async_added_to_hass(self) -> None: """Startup with initial state or previous state.""" + await super().async_added_to_hass() if self._initial_state is not None: enable_automation = self._initial_state _LOGGER.debug("Automation %s initial state %s from config " "initial_state", self.entity_id, enable_automation) else: - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state: enable_automation = state.state == STATE_ON self._last_triggered = state.attributes.get('last_triggered') @@ -285,12 +287,17 @@ async def async_trigger(self, variables, skip_condition=False, """ if skip_condition or self._cond_func(variables): self.async_set_context(context) + self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { + ATTR_NAME: self._name, + ATTR_ENTITY_ID: self.entity_id, + }, context=context) await self._async_action(self.entity_id, variables, context) self._last_triggered = utcnow() await self.async_update_ha_state() async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" + await super().async_will_remove_from_hass() await self.async_turn_off() async def async_enable(self): @@ -368,8 +375,6 @@ def _async_get_action(hass, config, name): async def action(entity_id, variables, context): """Execute an action.""" _LOGGER.info('Executing %s', name) - hass.components.logbook.async_log_entry( - name, 'has been triggered', DOMAIN, entity_id) await script_obj.async_run(variables, context) return action diff --git a/homeassistant/components/binary_sensor/blink.py b/homeassistant/components/binary_sensor/blink.py index 46751ce5394e1d..cd558f0368487d 100644 --- a/homeassistant/components/binary_sensor/blink.py +++ b/homeassistant/components/binary_sensor/blink.py @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data = hass.data[BLINK_DATA] devs = [] - for camera in data.sync.cameras: + for camera in data.cameras: for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: devs.append(BlinkBinarySensor(data, camera, sensor_type)) add_entities(devs, True) @@ -34,7 +34,7 @@ def __init__(self, data, camera, sensor_type): name, icon = BINARY_SENSORS[sensor_type] self._name = "{} {} {}".format(BLINK_DATA, camera, name) self._icon = icon - self._camera = data.sync.cameras[camera] + self._camera = data.cameras[camera] self._state = None self._unique_id = "{}-{}".format(self._camera.serial, self._type) diff --git a/homeassistant/components/binary_sensor/fibaro.py b/homeassistant/components/binary_sensor/fibaro.py index 124ff88a9a371d..ae8029e13f843c 100644 --- a/homeassistant/components/binary_sensor/fibaro.py +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -16,6 +16,8 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { + 'com.fibaro.floodSensor': ['Flood', 'mdi:water', 'flood'], + 'com.fibaro.motionSensor': ['Motion', 'mdi:run', 'motion'], 'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'], 'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'], 'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'], diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 20937af6bfcbfd..fb5b4c0bfc2277 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -3,59 +3,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.ihc/ """ -import voluptuous as vol - from homeassistant.components.binary_sensor import ( - BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA) + BinarySensorDevice) from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) -from homeassistant.components.ihc.const import CONF_INVERTING + IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import ( + CONF_INVERTING) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.const import ( - CONF_NAME, CONF_TYPE, CONF_ID, CONF_BINARY_SENSORS) -import homeassistant.helpers.config_validation as cv + CONF_TYPE) DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_BINARY_SENSORS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_INVERTING, default=False): cv.boolean, - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC binary sensor platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - product_cfg.get(CONF_TYPE), - product_cfg[CONF_INVERTING], - product) - devices.append(sensor) - else: - binary_sensors = config[CONF_BINARY_SENSORS] - for sensor_cfg in binary_sensors: - ihc_id = sensor_cfg[CONF_ID] - name = sensor_cfg[CONF_NAME] - sensor_type = sensor_cfg.get(CONF_TYPE) - inverting = sensor_cfg[CONF_INVERTING] - sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, - sensor_type, inverting) - devices.append(sensor) + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + sensor = IHCBinarySensor(ihc_controller, name, ihc_id, info, + product_cfg.get(CONF_TYPE), + product_cfg[CONF_INVERTING], + product) + devices.append(sensor) add_entities(devices) diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index f7bd353f3d1232..acbad0d0419030 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -45,8 +45,8 @@ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_OFF_DELAY): vol.All(vol.Coerce(int), vol.Range(min=0)), - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol + # Integrations should never expose unique_id through configuration. + # This is an exception because MQTT is a message transport, not a protocol vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -55,7 +55,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT binary sensor through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -63,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -71,17 +71,9 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT binary sensor.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - async_add_entities([MqttBinarySensor( - config, - discovery_hash - )]) + async_add_entities([MqttBinarySensor(config, discovery_hash)]) class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @@ -91,30 +83,18 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, def __init__(self, config, discovery_hash): """Initialize the MQTT binary sensor.""" self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = None 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) + qos = config.get(CONF_QOS) device_config = config.get(CONF_DEVICE) - MqttAvailability.__init__(self, availability_topic, self._qos, + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) @@ -122,37 +102,23 @@ def __init__(self, config, discovery_hash): 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 super().async_added_to_hass() 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) + self._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.""" + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + value_template.hass = self.hass + @callback def off_delay_listener(now): """Switch device off after a delay.""" @@ -163,34 +129,37 @@ def off_delay_listener(now): @callback def state_message_received(_topic, payload, _qos): """Handle a new received MQTT state message.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + value_template = self._config.get(CONF_VALUE_TEMPLATE) + if value_template is not None: + payload = value_template.async_render_with_possible_json_value( payload) - if payload == self._payload_on: + if payload == self._config.get(CONF_PAYLOAD_ON): self._state = True - elif payload == self._payload_off: + elif payload == self._config.get(CONF_PAYLOAD_OFF): self._state = False else: # Payload is not for this entity _LOGGER.warning('No matching payload found' ' for entity: %s with state_topic: %s', - self._name, self._state_topic) + self._config.get(CONF_NAME), + self._config.get(CONF_STATE_TOPIC)) return if self._delay_listener is not None: self._delay_listener() self._delay_listener = None - if (self._state and self._off_delay is not None): + off_delay = self._config.get(CONF_OFF_DELAY) + if (self._state and off_delay is not None): self._delay_listener = evt.async_call_later( - self.hass, self._off_delay, off_delay_listener) + self.hass, off_delay, off_delay_listener) self.async_schedule_update_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, - {'state_topic': {'topic': self._state_topic, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), 'msg_callback': state_message_received, - 'qos': self._qos}}) + 'qos': self._config.get(CONF_QOS)}}) async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" @@ -205,7 +174,7 @@ def should_poll(self): @property def name(self): """Return the name of the binary sensor.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -215,12 +184,12 @@ def is_on(self): @property def device_class(self): """Return the class of this sensor.""" - return self._device_class + return self._config.get(CONF_DEVICE_CLASS) @property def force_update(self): """Force update.""" - return self._force_update + return self._config.get(CONF_FORCE_UPDATE) @property def unique_id(self): diff --git a/homeassistant/components/binary_sensor/point.py b/homeassistant/components/binary_sensor/point.py index 90a8b0b5813484..29488d081305c5 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,10 +7,11 @@ import logging -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN as PARENT_DOMAIN, BinarySensorDevice) from homeassistant.components.point import MinutPointEntity from homeassistant.components.point.const import ( - DOMAIN as POINT_DOMAIN, NEW_DEVICE, SIGNAL_WEBHOOK) + DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -40,10 +41,16 @@ 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) + async def async_discover_sensor(device_id): + """Discover and add a discovered sensor.""" + client = hass.data[POINT_DOMAIN][config_entry.entry_id] + async_add_entities( + (MinutPointBinarySensor(client, device_id, device_class) + for device_class in EVENTS), True) + + async_dispatcher_connect( + hass, POINT_DISCOVERY_NEW.format(PARENT_DOMAIN, POINT_DOMAIN), + async_discover_sensor) class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/sense.py b/homeassistant/components/binary_sensor/sense.py index 1f83bffdcb62ef..a85a0c889d17ac 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -14,46 +14,48 @@ _LOGGER = logging.getLogger(__name__) BIN_SENSOR_CLASS = 'power' -MDI_ICONS = {'ac': 'air-conditioner', - 'aquarium': 'fish', - 'car': 'car-electric', - 'computer': 'desktop-classic', - 'cup': 'coffee', - 'dehumidifier': 'water-off', - 'dishes': 'dishwasher', - 'drill': 'toolbox', - 'fan': 'fan', - 'freezer': 'fridge-top', - 'fridge': 'fridge-bottom', - 'game': 'gamepad-variant', - 'garage': 'garage', - 'grill': 'stove', - 'heat': 'fire', - 'heater': 'radiatior', - 'humidifier': 'water', - 'kettle': 'kettle', - 'leafblower': 'leaf', - 'lightbulb': 'lightbulb', - 'media_console': 'set-top-box', - 'modem': 'router-wireless', - 'outlet': 'power-socket-us', - 'papershredder': 'shredder', - 'printer': 'printer', - 'pump': 'water-pump', - 'settings': 'settings', - 'skillet': 'pot', - 'smartcamera': 'webcam', - 'socket': 'power-plug', - 'sound': 'speaker', - 'stove': 'stove', - 'trash': 'trash-can', - 'tv': 'television', - 'vacuum': 'robot-vacuum', - 'washer': 'washing-machine'} +MDI_ICONS = { + 'ac': 'air-conditioner', + 'aquarium': 'fish', + 'car': 'car-electric', + 'computer': 'desktop-classic', + 'cup': 'coffee', + 'dehumidifier': 'water-off', + 'dishes': 'dishwasher', + 'drill': 'toolbox', + 'fan': 'fan', + 'freezer': 'fridge-top', + 'fridge': 'fridge-bottom', + 'game': 'gamepad-variant', + 'garage': 'garage', + 'grill': 'stove', + 'heat': 'fire', + 'heater': 'radiatior', + 'humidifier': 'water', + 'kettle': 'kettle', + 'leafblower': 'leaf', + 'lightbulb': 'lightbulb', + 'media_console': 'set-top-box', + 'modem': 'router-wireless', + 'outlet': 'power-socket-us', + 'papershredder': 'shredder', + 'printer': 'printer', + 'pump': 'water-pump', + 'settings': 'settings', + 'skillet': 'pot', + 'smartcamera': 'webcam', + 'socket': 'power-plug', + 'sound': 'speaker', + 'stove': 'stove', + 'trash': 'trash-can', + 'tv': 'television', + 'vacuum': 'robot-vacuum', + 'washer': 'washing-machine', +} def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Sense sensor.""" + """Set up the Sense binary sensor.""" if discovery_info is None: return @@ -67,14 +69,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" - return 'mdi:' + MDI_ICONS.get(sense_icon, 'power-plug') + return 'mdi:{}'.format(MDI_ICONS.get(sense_icon, 'power-plug')) class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" def __init__(self, data, device): - """Initialize the sensor.""" + """Initialize the Sense binary sensor.""" self._name = device['name'] self._id = device['id'] self._icon = sense_to_mdi(device['icon']) diff --git a/homeassistant/components/binary_sensor/tahoma.py b/homeassistant/components/binary_sensor/tahoma.py index 7af5a730c43f3b..73035a2da0d786 100644 --- a/homeassistant/components/binary_sensor/tahoma.py +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -41,6 +41,7 @@ def __init__(self, tahoma_device, controller): self._state = None self._icon = None self._battery = None + self._available = False @property def is_on(self): @@ -71,6 +72,11 @@ def device_state_attributes(self): attr[ATTR_BATTERY_LEVEL] = self._battery return attr + @property + def available(self): + """Return True if entity is available.""" + return self._available + def update(self): """Update the state.""" self.controller.get_states([self.tahoma_device]) @@ -82,11 +88,13 @@ def update(self): self._state = STATE_ON if 'core:SensorDefectState' in self.tahoma_device.active_states: - # Set to 'lowBattery' for low battery warning. + # 'lowBattery' for low battery warning. 'dead' for not available. self._battery = self.tahoma_device.active_states[ 'core:SensorDefectState'] + self._available = bool(self._battery != 'dead') else: self._battery = None + self._available = True if self._state == STATE_ON: self._icon = "mdi:fire" diff --git a/homeassistant/components/binary_sensor/tellduslive.py b/homeassistant/components/binary_sensor/tellduslive.py index 450a5e580bdc09..7f60e40c68bb43 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -9,8 +9,9 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,8 +20,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tellstick sensors.""" if discovery_info is None: return + client = hass.data[tellduslive.DOMAIN] add_entities( - TelldusLiveSensor(hass, binary_sensor) + TelldusLiveSensor(client, binary_sensor) for binary_sensor in discovery_info ) diff --git a/homeassistant/components/binary_sensor/volvooncall.py b/homeassistant/components/binary_sensor/volvooncall.py index e70d3098874405..e7092ff16d5273 100644 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ b/homeassistant/components/binary_sensor/volvooncall.py @@ -6,17 +6,19 @@ """ import logging -from homeassistant.components.volvooncall import VolvoEntity -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, DEVICE_CLASSES) _LOGGER = logging.getLogger(__name__) -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 Volvo sensors.""" if discovery_info is None: return - add_entities([VolvoSensor(hass, *discovery_info)]) + async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) class VolvoSensor(VolvoEntity, BinarySensorDevice): @@ -25,14 +27,11 @@ class VolvoSensor(VolvoEntity, BinarySensorDevice): @property def is_on(self): """Return True if the binary sensor is on.""" - val = getattr(self.vehicle, self._attribute) - if self._attribute == 'bulb_failures': - return bool(val) - if self._attribute in ['doors', 'windows']: - return any([val[key] for key in val if 'Open' in key]) - return val != 'Normal' + return self.instrument.is_on @property def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" - return 'safety' + if self.instrument.device_class in DEVICE_CLASSES: + return self.instrument.device_class + return None diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 9365ba42cc1edc..62c57f0288b052 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,7 +7,11 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice -from homeassistant.components import zha +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -26,23 +30,43 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation binary sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation binary sensors.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation binary sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if binary_sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + binary_sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] - from zigpy.zcl.clusters.general import OnOff - from zigpy.zcl.clusters.security import IasZone - if IasZone.cluster_id in discovery_info['in_clusters']: - await _async_setup_iaszone(hass, config, async_add_entities, - discovery_info) - elif OnOff.cluster_id in discovery_info['out_clusters']: - await _async_setup_remote(hass, config, async_add_entities, - discovery_info) +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA binary sensors.""" + entities = [] + for discovery_info in discovery_infos: + from zigpy.zcl.clusters.general import OnOff + from zigpy.zcl.clusters.security import IasZone + if IasZone.cluster_id in discovery_info['in_clusters']: + entities.append(await _async_setup_iaszone(discovery_info)) + elif OnOff.cluster_id in discovery_info['out_clusters']: + entities.append(await _async_setup_remote(discovery_info)) + + async_add_entities(entities, update_before_add=True) -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): + +async def _async_setup_iaszone(discovery_info): device_class = None from zigpy.zcl.clusters.security import IasZone cluster = discovery_info['in_clusters'][IasZone.cluster_id] @@ -58,13 +82,10 @@ async def _async_setup_iaszone(hass, config, async_add_entities, # If we fail to read from the device, use a non-specific class pass - sensor = BinarySensor(device_class, **discovery_info) - async_add_entities([sensor], update_before_add=True) - + return BinarySensor(device_class, **discovery_info) -async def _async_setup_remote(hass, config, async_add_entities, - discovery_info): +async def _async_setup_remote(discovery_info): remote = Remote(**discovery_info) if discovery_info['new_join']: @@ -72,21 +93,21 @@ async def _async_setup_remote(hass, config, async_add_entities, out_clusters = discovery_info['out_clusters'] if OnOff.cluster_id in out_clusters: cluster = out_clusters[OnOff.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=0, max_report=600, reportable_change=1 ) if LevelControl.cluster_id in out_clusters: cluster = out_clusters[LevelControl.cluster_id] - await zha.configure_reporting( + await helpers.configure_reporting( remote.entity_id, cluster, 0, min_report=1, max_report=600, reportable_change=1 ) - async_add_entities([remote], update_before_add=True) + return remote -class BinarySensor(zha.Entity, BinarySensorDevice): +class BinarySensor(ZhaEntity, BinarySensorDevice): """The ZHA Binary Sensor.""" _domain = DOMAIN @@ -130,16 +151,16 @@ async def async_update(self): """Retrieve latest state.""" from zigpy.types.basic import uint16_t - result = await zha.safe_read(self._endpoint.ias_zone, - ['zone_status'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.ias_zone, + ['zone_status'], + allow_cache=False, + only_cache=(not self._initialized)) state = result.get('zone_status', self._state) if isinstance(state, (int, uint16_t)): self._state = result.get('zone_status', self._state) & 3 -class Remote(zha.Entity, BinarySensorDevice): +class Remote(ZhaEntity, BinarySensorDevice): """ZHA switch/remote controller/button.""" _domain = DOMAIN @@ -252,7 +273,7 @@ def set_state(self, state): async def async_update(self): """Retrieve latest state.""" from zigpy.zcl.clusters.general import OnOff - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.out_clusters[OnOff.cluster_id], ['on_off'], allow_cache=False, diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 62e73a52cc8bb2..a56885a22a994b 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.3'] +REQUIREMENTS = ['blinkpy==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def setup(hass, config): def trigger_camera(call): """Trigger a camera.""" - cameras = hass.data[BLINK_DATA].sync.cameras + cameras = hass.data[BLINK_DATA].cameras name = call.data[CONF_NAME] if name in cameras: cameras[name].snap_picture() @@ -148,7 +148,7 @@ async def async_handle_save_video_service(hass, call): def _write_video(camera_name, video_path): """Call video write.""" - all_cameras = hass.data[BLINK_DATA].sync.cameras + all_cameras = hass.data[BLINK_DATA].cameras if camera_name in all_cameras: all_cameras[camera_name].video_to_file(video_path) diff --git a/homeassistant/components/camera/blink.py b/homeassistant/components/camera/blink.py index 510c2ab2563964..e904791445630a 100644 --- a/homeassistant/components/camera/blink.py +++ b/homeassistant/components/camera/blink.py @@ -23,7 +23,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] devs = [] - for name, camera in data.sync.cameras.items(): + for name, camera in data.cameras.items(): devs.append(BlinkCamera(data, name, camera)) add_entities(devs) diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 9db7c1381824e3..2819b0e6ec418b 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -60,13 +60,20 @@ async def async_setup_platform(hass, config, async_add_entities, def extract_image_from_mjpeg(stream): """Take in a MJPEG stream object, return the jpg from it.""" data = b'' + for chunk in stream: data += chunk - jpg_start = data.find(b'\xff\xd8') jpg_end = data.find(b'\xff\xd9') - if jpg_start != -1 and jpg_end != -1: - jpg = data[jpg_start:jpg_end + 2] - return jpg + + if jpg_end == -1: + continue + + jpg_start = data.find(b'\xff\xd8') + + if jpg_start == -1: + continue + + return data[jpg_start:jpg_end + 2] class MjpegCamera(Camera): diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 83d873116460e5..e5a0d6727569aa 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -10,14 +10,15 @@ import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \ + HTTP_HEADER_HA_AUTH from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util -from . import async_get_still_stream +from homeassistant.components.camera import async_get_still_stream -REQUIREMENTS = ['pillow==5.2.0'] +REQUIREMENTS = ['pillow==5.3.0'] _LOGGER = logging.getLogger(__name__) @@ -26,21 +27,34 @@ CONF_IMAGE_QUALITY = 'image_quality' CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate' CONF_MAX_IMAGE_WIDTH = 'max_image_width' +CONF_MAX_IMAGE_HEIGHT = 'max_image_height' CONF_MAX_STREAM_WIDTH = 'max_stream_width' +CONF_MAX_STREAM_HEIGHT = 'max_stream_height' +CONF_IMAGE_TOP = 'image_top' +CONF_IMAGE_LEFT = 'image_left' CONF_STREAM_QUALITY = 'stream_quality' +MODE_RESIZE = 'resize' +MODE_CROP = 'crop' + DEFAULT_BASENAME = "Camera Proxy" DEFAULT_QUALITY = 75 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, + vol.Optional(CONF_MODE, default=MODE_RESIZE): + vol.In([MODE_RESIZE, MODE_CROP]), vol.Optional(CONF_IMAGE_QUALITY): int, vol.Optional(CONF_IMAGE_REFRESH_RATE): float, vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_MAX_IMAGE_HEIGHT): int, vol.Optional(CONF_MAX_STREAM_WIDTH): int, - vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAX_STREAM_HEIGHT): int, + vol.Optional(CONF_IMAGE_LEFT): int, + vol.Optional(CONF_IMAGE_TOP): int, vol.Optional(CONF_STREAM_QUALITY): int, }) @@ -51,26 +65,37 @@ async def async_setup_platform( async_add_entities([ProxyCamera(hass, config)]) -def _resize_image(image, opts): - """Resize image.""" +def _precheck_image(image, opts): + """Perform some pre-checks on the given image.""" from PIL import Image import io if not opts: - return image - - quality = opts.quality or DEFAULT_QUALITY - new_width = opts.max_width - + raise ValueError() try: img = Image.open(io.BytesIO(image)) except IOError: - return image + _LOGGER.warning("Failed to open image") + raise ValueError() imgfmt = str(img.format) if imgfmt not in ('PNG', 'JPEG'): - _LOGGER.debug("Image is of unsupported type: %s", imgfmt) + _LOGGER.warning("Image is of unsupported type: %s", imgfmt) + raise ValueError() + return img + + +def _resize_image(image, opts): + """Resize image.""" + from PIL import Image + import io + + try: + img = _precheck_image(image, opts) + except ValueError: return image + quality = opts.quality or DEFAULT_QUALITY + new_width = opts.max_width (old_width, old_height) = img.size old_size = len(image) if old_width <= new_width: @@ -87,7 +112,7 @@ def _resize_image(image, opts): img.save(imgbuf, 'JPEG', optimize=True, quality=quality) newimage = imgbuf.getvalue() if not opts.force_resize and len(newimage) >= old_size: - _LOGGER.debug("Using original image(%d bytes) " + _LOGGER.debug("Using original image (%d bytes) " "because resized image (%d bytes) is not smaller", old_size, len(newimage)) return image @@ -98,12 +123,50 @@ def _resize_image(image, opts): return newimage +def _crop_image(image, opts): + """Crop image.""" + import io + + try: + img = _precheck_image(image, opts) + except ValueError: + return image + + quality = opts.quality or DEFAULT_QUALITY + (old_width, old_height) = img.size + old_size = len(image) + if opts.top is None: + opts.top = 0 + if opts.left is None: + opts.left = 0 + if opts.max_width is None or opts.max_width > old_width - opts.left: + opts.max_width = old_width - opts.left + if opts.max_height is None or opts.max_height > old_height - opts.top: + opts.max_height = old_height - opts.top + + img = img.crop((opts.left, opts.top, + opts.left+opts.max_width, opts.top+opts.max_height)) + imgbuf = io.BytesIO() + img.save(imgbuf, 'JPEG', optimize=True, quality=quality) + newimage = imgbuf.getvalue() + + _LOGGER.debug( + "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", + old_width, old_height, old_size, opts.max_width, opts.max_height, + len(newimage)) + return newimage + + class ImageOpts(): """The representation of image options.""" - def __init__(self, max_width, quality, force_resize): + def __init__(self, max_width, max_height, left, top, + quality, force_resize): """Initialize image options.""" self.max_width = max_width + self.max_height = max_height + self.left = left + self.top = top self.quality = quality self.force_resize = force_resize @@ -125,11 +188,18 @@ def __init__(self, hass, config): "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera)) self._image_opts = ImageOpts( config.get(CONF_MAX_IMAGE_WIDTH), + config.get(CONF_MAX_IMAGE_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), config.get(CONF_IMAGE_QUALITY), config.get(CONF_FORCE_RESIZE)) self._stream_opts = ImageOpts( - config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_STREAM_QUALITY), + config.get(CONF_MAX_STREAM_WIDTH), + config.get(CONF_MAX_STREAM_HEIGHT), + config.get(CONF_IMAGE_LEFT), + config.get(CONF_IMAGE_TOP), + config.get(CONF_STREAM_QUALITY), True) self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) @@ -141,6 +211,7 @@ def __init__(self, hass, config): self._headers = ( {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} if self.hass.config.api.api_password is not None else None) + self._mode = config.get(CONF_MODE) def camera_image(self): """Return camera image.""" @@ -162,8 +233,12 @@ async def async_camera_image(self): _LOGGER.error("Error getting original camera image") return self._last_image - image = await self.hass.async_add_job( - _resize_image, image.content, self._image_opts) + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + image = await self.hass.async_add_executor_job( + job, image.content, self._image_opts) if self._cache_images: self._last_image = image @@ -192,7 +267,11 @@ async def _async_stream_image(self): if not image: return None except HomeAssistantError: - raise asyncio.CancelledError - - return await self.hass.async_add_job( - _resize_image, image.content, self._stream_opts) + raise asyncio.CancelledError() + + if self._mode == MODE_RESIZE: + job = _resize_image + else: + job = _crop_image + return await self.hass.async_add_executor_job( + job, image.content, self._stream_opts) diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py index c9deca1309d699..36c4a3109baba2 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -5,35 +5,33 @@ https://home-assistant.io/components/camera.push/ """ import logging +import asyncio from collections import deque from datetime import timedelta import voluptuous as vol +import aiohttp +import async_timeout from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ - STATE_IDLE, STATE_RECORDING + STATE_IDLE, STATE_RECORDING, DOMAIN from homeassistant.core import callback -from homeassistant.components.http.view import KEY_AUTHENTICATED,\ - HomeAssistantView -from homeassistant.const import CONF_NAME, CONF_TIMEOUT,\ - HTTP_NOT_FOUND, HTTP_UNAUTHORIZED, HTTP_BAD_REQUEST +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +DEPENDENCIES = ['webhook'] -DEPENDENCIES = ['http'] +_LOGGER = logging.getLogger(__name__) CONF_BUFFER_SIZE = 'buffer' CONF_IMAGE_FIELD = 'field' -CONF_TOKEN = 'token' DEFAULT_NAME = "Push Camera" ATTR_FILENAME = 'filename' ATTR_LAST_TRIP = 'last_trip' -ATTR_TOKEN = 'token' PUSH_CAMERA_DATA = 'push_camera' @@ -43,7 +41,7 @@ vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( cv.time_period, cv.positive_timedelta), vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, - vol.Optional(CONF_TOKEN): vol.All(cv.string, vol.Length(min=8)), + vol.Required(CONF_WEBHOOK_ID): cv.string, }) @@ -53,69 +51,43 @@ async def async_setup_platform(hass, config, async_add_entities, if PUSH_CAMERA_DATA not in hass.data: hass.data[PUSH_CAMERA_DATA] = {} - cameras = [PushCamera(config[CONF_NAME], + webhook_id = config.get(CONF_WEBHOOK_ID) + + cameras = [PushCamera(hass, + config[CONF_NAME], config[CONF_BUFFER_SIZE], config[CONF_TIMEOUT], - config.get(CONF_TOKEN))] - - hass.http.register_view(CameraPushReceiver(hass, - config[CONF_IMAGE_FIELD])) + config[CONF_IMAGE_FIELD], + webhook_id)] async_add_entities(cameras) -class CameraPushReceiver(HomeAssistantView): - """Handle pushes from remote camera.""" +async def handle_webhook(hass, webhook_id, request): + """Handle incoming webhook POST with image files.""" + try: + with async_timeout.timeout(5, loop=hass.loop): + data = dict(await request.post()) + except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error: + _LOGGER.error("Could not get information from POST <%s>", error) + return - url = "/api/camera_push/{entity_id}" - name = 'api:camera_push:camera_entity' - requires_auth = False + camera = hass.data[PUSH_CAMERA_DATA][webhook_id] - def __init__(self, hass, image_field): - """Initialize CameraPushReceiver with camera entity.""" - self._cameras = hass.data[PUSH_CAMERA_DATA] - self._image = image_field + if camera.image_field not in data: + _LOGGER.warning("Webhook call without POST parameter <%s>", + camera.image_field) + return - async def post(self, request, entity_id): - """Accept the POST from Camera.""" - _camera = self._cameras.get(entity_id) - - if _camera is None: - _LOGGER.error("Unknown %s", entity_id) - status = HTTP_NOT_FOUND if request[KEY_AUTHENTICATED]\ - else HTTP_UNAUTHORIZED - return self.json_message('Unknown {}'.format(entity_id), - status) - - # Supports HA authentication and token based - # when token has been configured - authenticated = (request[KEY_AUTHENTICATED] or - (_camera.token is not None and - request.query.get('token') == _camera.token)) - - if not authenticated: - return self.json_message( - 'Invalid authorization credentials for {}'.format(entity_id), - HTTP_UNAUTHORIZED) - - try: - data = await request.post() - _LOGGER.debug("Received Camera push: %s", data[self._image]) - await _camera.update_image(data[self._image].file.read(), - data[self._image].filename) - except ValueError as value_error: - _LOGGER.error("Unknown value %s", value_error) - return self.json_message('Invalid POST', HTTP_BAD_REQUEST) - except KeyError as key_error: - _LOGGER.error('In your POST message %s', key_error) - return self.json_message('{} missing'.format(self._image), - HTTP_BAD_REQUEST) + await camera.update_image(data[camera.image_field].file.read(), + data[camera.image_field].filename) class PushCamera(Camera): """The representation of a Push camera.""" - def __init__(self, name, buffer_size, timeout, token): + def __init__(self, hass, name, buffer_size, timeout, image_field, + webhook_id): """Initialize push camera component.""" super().__init__() self._name = name @@ -126,11 +98,28 @@ def __init__(self, name, buffer_size, timeout, token): self._timeout = timeout self.queue = deque([], buffer_size) self._current_image = None - self.token = token + self._image_field = image_field + self.webhook_id = webhook_id + self.webhook_url = \ + hass.components.webhook.async_generate_url(webhook_id) async def async_added_to_hass(self): """Call when entity is added to hass.""" - self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self + self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self + + try: + self.hass.components.webhook.async_register(DOMAIN, + self.name, + self.webhook_id, + handle_webhook) + except ValueError: + _LOGGER.error("In <%s>, webhook_id <%s> already used", + self.name, self.webhook_id) + + @property + def image_field(self): + """HTTP field containing the image file.""" + return self._image_field @property def state(self): @@ -189,6 +178,5 @@ def device_state_attributes(self): name: value for name, value in ( (ATTR_LAST_TRIP, self._last_trip), (ATTR_FILENAME, self._filename), - (ATTR_TOKEN, self.token), ) if value is not None } diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 0e65ac77c1fa03..50e7c3d8fe2938 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -10,12 +10,12 @@ import requests import voluptuous as vol -from homeassistant.const import CONF_PORT +from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import PlatformNotReady -REQUIREMENTS = ['uvcclient==0.10.1'] +REQUIREMENTS = ['uvcclient==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -25,12 +25,14 @@ DEFAULT_PASSWORD = 'ubnt' DEFAULT_PORT = 7080 +DEFAULT_SSL = False PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_NVR): cv.string, vol.Required(CONF_KEY): cv.string, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, }) @@ -40,11 +42,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): key = config[CONF_KEY] password = config[CONF_PASSWORD] port = config[CONF_PORT] + ssl = config[CONF_SSL] from uvcclient import nvr try: # Exceptions may be raised in all method calls to the nvr library. - nvrconn = nvr.UVCRemote(addr, port, key) + nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl) cameras = nvrconn.index() identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 4a5c325889371f..38c78bfdb3d699 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -192,6 +192,11 @@ def name(self): """Return the name of the thermostat, if any.""" return self._api.name + @property + def unique_id(self): + """Return a unique ID.""" + return self._api.mac + @property def temperature_unit(self): """Return the unit of measurement which this thermostat uses.""" diff --git a/homeassistant/components/climate/evohome.py b/homeassistant/components/climate/evohome.py index f0631228fd843c..fd58e6c01e868d 100644 --- a/homeassistant/components/climate/evohome.py +++ b/homeassistant/components/climate/evohome.py @@ -1,7 +1,7 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating -zones (e.g. TRVs, relays) and, optionally, a DHW controller. +zones (e.g. TRVs, relays). For more details about this platform, please refer to the documentation at https://home-assistant.io/components/climate.evohome/ @@ -13,29 +13,34 @@ from requests.exceptions import HTTPError from homeassistant.components.climate import ( - ClimateDevice, - STATE_AUTO, - STATE_ECO, - STATE_OFF, - SUPPORT_OPERATION_MODE, + STATE_AUTO, STATE_ECO, STATE_MANUAL, STATE_OFF, SUPPORT_AWAY_MODE, + SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + ClimateDevice ) from homeassistant.components.evohome import ( - CONF_LOCATION_IDX, - DATA_EVOHOME, - MAX_TEMP, - MIN_TEMP, - SCAN_INTERVAL_MAX + DATA_EVOHOME, DISPATCHER_EVOHOME, + CONF_LOCATION_IDX, SCAN_INTERVAL_DEFAULT, + EVO_PARENT, EVO_CHILD, + GWS, TCS, ) from homeassistant.const import ( CONF_SCAN_INTERVAL, - PRECISION_TENTHS, - TEMP_CELSIUS, HTTP_TOO_MANY_REQUESTS, + PRECISION_HALVES, + TEMP_CELSIUS ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + dispatcher_send, + async_dispatcher_connect +) + _LOGGER = logging.getLogger(__name__) -# these are for the controller's opmode/state and the zone's state +# the Controller's opmode/state and the zone's (inherited) state EVO_RESET = 'AutoWithReset' EVO_AUTO = 'Auto' EVO_AUTOECO = 'AutoWithEco' @@ -44,7 +49,14 @@ EVO_CUSTOM = 'Custom' EVO_HEATOFF = 'HeatingOff' -EVO_STATE_TO_HA = { +# these are for Zones' opmode, and state +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' + +# for the Controller. NB: evohome treats Away mode as a mode in/of itself, +# where HA considers it to 'override' the exising operating mode +TCS_STATE_TO_HA = { EVO_RESET: STATE_AUTO, EVO_AUTO: STATE_AUTO, EVO_AUTOECO: STATE_ECO, @@ -53,156 +65,415 @@ EVO_CUSTOM: STATE_AUTO, EVO_HEATOFF: STATE_OFF } - -HA_STATE_TO_EVO = { +HA_STATE_TO_TCS = { STATE_AUTO: EVO_AUTO, STATE_ECO: EVO_AUTOECO, STATE_OFF: EVO_HEATOFF } +TCS_OP_LIST = list(HA_STATE_TO_TCS) + +# the Zones' opmode; their state is usually 'inherited' from the TCS +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' + +# for the Zones... +ZONE_STATE_TO_HA = { + EVO_FOLLOW: STATE_AUTO, + EVO_TEMPOVER: STATE_MANUAL, + EVO_PERMOVER: STATE_MANUAL +} +HA_STATE_TO_ZONE = { + STATE_AUTO: EVO_FOLLOW, + STATE_MANUAL: EVO_PERMOVER +} +ZONE_OP_LIST = list(HA_STATE_TO_ZONE) -HA_OP_LIST = list(HA_STATE_TO_EVO) - -# these are used to help prevent E501 (line too long) violations -GWS = 'gateways' -TCS = 'temperatureControlSystems' - -# debug codes - these happen occasionally, but the cause is unknown -EVO_DEBUG_NO_RECENT_UPDATES = '0x01' -EVO_DEBUG_NO_STATUS = '0x02' - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create a Honeywell (EMEA/EU) evohome CH/DHW system. - - An evohome system consists of: a controller, with 0-12 heating zones (e.g. - TRVs, relays) and, optionally, a DHW controller (a HW boiler). - Here, we add the controller only. - """ +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Create the evohome Controller, and its Zones, if any.""" evo_data = hass.data[DATA_EVOHOME] client = evo_data['client'] loc_idx = evo_data['params'][CONF_LOCATION_IDX] - # evohomeclient has no defined way of accessing non-default location other - # than using a protected member, such as below + # evohomeclient has exposed no means of accessing non-default location + # (i.e. loc_idx > 0) other than using a protected member, such as below tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa E501; pylint: disable=protected-access _LOGGER.debug( - "setup_platform(): Found Controller: id: %s [%s], type: %s", + "setup_platform(): Found Controller, id=%s [%s], " + "name=%s (location_idx=%s)", tcs_obj_ref.systemId, + tcs_obj_ref.modelType, tcs_obj_ref.location.name, - tcs_obj_ref.modelType + loc_idx ) - parent = EvoController(evo_data, client, tcs_obj_ref) - add_entities([parent], update_before_add=True) + controller = EvoController(evo_data, client, tcs_obj_ref) + zones = [] -class EvoController(ClimateDevice): - """Base for a Honeywell evohome hub/Controller device. + for zone_idx in tcs_obj_ref.zones: + zone_obj_ref = tcs_obj_ref.zones[zone_idx] + _LOGGER.debug( + "setup_platform(): Found Zone, id=%s [%s], " + "name=%s", + zone_obj_ref.zoneId, + zone_obj_ref.zone_type, + zone_obj_ref.name + ) + zones.append(EvoZone(evo_data, client, zone_obj_ref)) - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. - """ + entities = [controller] + zones - def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity. + async_add_entities(entities, update_before_add=False) - Most read-only properties are set here. So are pseudo read-only, - for example name (which _could_ change between update()s). - """ - self.client = client - self._obj = obj_ref - self._id = obj_ref.systemId - self._name = evo_data['config']['locationInfo']['name'] +class EvoClimateDevice(ClimateDevice): + """Base for a Honeywell evohome Climate device.""" + + # pylint: disable=no-member + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome entity.""" + self._client = client + self._obj = obj_ref - self._config = evo_data['config'][GWS][0][TCS][0] self._params = evo_data['params'] self._timers = evo_data['timers'] - - self._timers['statusUpdated'] = datetime.min self._status = {} self._available = False # should become True after first update() - def _handle_requests_exceptions(self, err): - # evohomeclient v2 api (>=0.2.7) exposes requests exceptions, incl.: - # - HTTP_BAD_REQUEST, is usually Bad user credentials - # - HTTP_TOO_MANY_REQUESTS, is api usuage limit exceeded - # - HTTP_SERVICE_UNAVAILABLE, is often Vendor's fault + async def async_added_to_hass(self): + """Run when entity about to be added.""" + async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect) + + @callback + def _connect(self, packet): + if packet['to'] & self._type and packet['signal'] == 'refresh': + self.async_schedule_update_ha_state(force_refresh=True) + def _handle_requests_exceptions(self, err): if err.response.status_code == HTTP_TOO_MANY_REQUESTS: - # execute a back off: pause, and reduce rate - old_scan_interval = self._params[CONF_SCAN_INTERVAL] - new_scan_interval = min(old_scan_interval * 2, SCAN_INTERVAL_MAX) - self._params[CONF_SCAN_INTERVAL] = new_scan_interval + # execute a backoff: pause, and also reduce rate + old_interval = self._params[CONF_SCAN_INTERVAL] + new_interval = min(old_interval, SCAN_INTERVAL_DEFAULT) * 2 + self._params[CONF_SCAN_INTERVAL] = new_interval _LOGGER.warning( - "API rate limit has been exceeded: increasing '%s' from %s to " - "%s seconds, and suspending polling for %s seconds.", + "API rate limit has been exceeded. Suspending polling for %s " + "seconds, and increasing '%s' from %s to %s seconds.", + new_interval * 3, CONF_SCAN_INTERVAL, - old_scan_interval, - new_scan_interval, - new_scan_interval * 3 + old_interval, + new_interval, ) - self._timers['statusUpdated'] = datetime.now() + \ - timedelta(seconds=new_scan_interval * 3) + self._timers['statusUpdated'] = datetime.now() + new_interval * 3 else: - raise err + raise err # we dont handle any other HTTPErrors @property - def name(self): + def name(self) -> str: """Return the name to use in the frontend UI.""" return self._name @property - def available(self): - """Return True if the device is available. + def icon(self): + """Return the icon to use in the frontend UI.""" + return self._icon - All evohome entities are initially unavailable. Once HA has started, - state data is then retrieved by the Controller, and then the children - will get a state (e.g. operating_mode, current_temperature). + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Climate device. - However, evohome entities can become unavailable for other reasons. + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. """ + return {'status': self._status} + + @property + def available(self) -> bool: + """Return True if the device is currently available.""" return self._available @property def supported_features(self): - """Get the list of supported features of the Controller.""" - return SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE + """Get the list of supported features of the device.""" + return self._supported_features @property - def device_state_attributes(self): - """Return the device state attributes of the controller. + def operation_list(self): + """Return the list of available operations.""" + return self._operation_list + + @property + def temperature_unit(self): + """Return the temperature unit to use in the frontend UI.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return the temperature precision to use in the frontend UI.""" + return PRECISION_HALVES + + +class EvoZone(EvoClimateDevice): + """Base for a Honeywell evohome Zone device.""" + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Zone.""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.zoneId + self._name = obj_ref.name + self._icon = "mdi:radiator" + self._type = EVO_CHILD + + for _zone in evo_data['config'][GWS][0][TCS][0]['zones']: + if _zone['zoneId'] == self._id: + self._config = _zone + break + self._status = {} - This is operating mode state data that is not available otherwise, due - to the restrictions placed upon ClimateDevice properties, etc by HA. + self._operation_list = ZONE_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_TARGET_TEMPERATURE | \ + SUPPORT_ON_OFF + + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. """ - data = {} - data['systemMode'] = self._status['systemModeStatus']['mode'] - data['isPermanent'] = self._status['systemModeStatus']['isPermanent'] - if 'timeUntil' in self._status['systemModeStatus']: - data['timeUntil'] = self._status['systemModeStatus']['timeUntil'] - data['activeFaults'] = self._status['activeFaults'] - return data + return self._config['setpointCapabilities']['minHeatSetpoint'] @property - def operation_list(self): - """Return the list of available operations.""" - return HA_OP_LIST + def max_temp(self): + """Return the minimum target temperature of a evohome Zone. + + The default is 35 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['maxHeatSetpoint'] + + @property + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] + + @property + def current_temperature(self): + """Return the current temperature of the evohome Zone.""" + return self._status['temperatureStatus']['temperature'] @property def current_operation(self): - """Return the operation mode of the evohome entity.""" - return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + """Return the current operating mode of the evohome Zone. + + The evohome Zones that are in 'FollowSchedule' mode inherit their + actual operating mode from the Controller. + """ + evo_data = self.hass.data[DATA_EVOHOME] + + system_mode = evo_data['status']['systemModeStatus']['mode'] + setpoint_mode = self._status['setpointStatus']['setpointMode'] + + if setpoint_mode == EVO_FOLLOW: + # then inherit state from the controller + if system_mode == EVO_RESET: + current_operation = TCS_STATE_TO_HA.get(EVO_AUTO) + else: + current_operation = TCS_STATE_TO_HA.get(system_mode) + else: + current_operation = ZONE_STATE_TO_HA.get(setpoint_mode) + + return current_operation + + @property + def is_on(self) -> bool: + """Return True if the evohome Zone is off. + + A Zone is considered off if its target temp is set to its minimum, and + it is not following its schedule (i.e. not in 'FollowSchedule' mode). + """ + is_off = \ + self.target_temperature == self.min_temp and \ + self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER + return not is_off + + def _set_temperature(self, temperature, until=None): + """Set the new target temperature of a Zone. + + temperature is required, until can be: + - strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or + - None for PermanentOverride (i.e. indefinitely) + """ + try: + self._obj.set_temperature(temperature, until) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + def set_temperature(self, **kwargs): + """Set new target temperature, indefinitely.""" + self._set_temperature(kwargs['temperature'], until=None) + + def turn_on(self): + """Turn the evohome Zone on. + + This is achieved by setting the Zone to its 'FollowSchedule' mode. + """ + self._set_operation_mode(EVO_FOLLOW) + + def turn_off(self): + """Turn the evohome Zone off. + + This is achieved by setting the Zone to its minimum temperature, + indefinitely (i.e. 'PermanentOverride' mode). + """ + self._set_temperature(self.min_temp, until=None) + + def set_operation_mode(self, operation_mode): + """Set an operating mode for a Zone. + + Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be + enabled via turn_off method. + + NB: evohome Zones do not have an operating mode as understood by HA. + Instead they usually 'inherit' an operating mode from their controller. + + More correctly, these Zones are in a follow mode, 'FollowSchedule', + where their setpoint temperatures are a function of their schedule, and + the Controller's operating_mode, e.g. Economy mode is their scheduled + setpoint less (usually) 3C. + + Thus, you cannot set a Zone to Away mode, but the location (i.e. the + Controller) is set to Away and each Zones's setpoints are adjusted + accordingly to some lower temperature. + + However, Zones can override these setpoints, either for a specified + period of time, 'TemporaryOverride', after which they will revert back + to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'. + """ + self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode)) + + def _set_operation_mode(self, operation_mode): + if operation_mode == EVO_FOLLOW: + try: + self._obj.cancel_temp_override(self._obj) + except HTTPError as err: + self._handle_exception("HTTPError", str(err)) # noqa: E501; pylint: disable=no-member + + elif operation_mode == EVO_TEMPOVER: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not yet implemented", + operation_mode + ) + + elif operation_mode == EVO_PERMOVER: + self._set_temperature(self.target_temperature, until=None) + + else: + _LOGGER.error( + "_set_operation_mode(op_mode=%s): mode not valid", + operation_mode + ) + + @property + def should_poll(self) -> bool: + """Return False as evohome child devices should never be polled. + + The evohome Controller will inform its children when to update(). + """ + return False + + def update(self): + """Process the evohome Zone's state data.""" + evo_data = self.hass.data[DATA_EVOHOME] + + for _zone in evo_data['status']['zones']: + if _zone['zoneId'] == self._id: + self._status = _zone + break + + self._available = True + + +class EvoController(EvoClimateDevice): + """Base for a Honeywell evohome hub/Controller device. + + The Controller (aka TCS, temperature control system) is the parent of all + the child (CH/DHW) devices. It is also a Climate device. + """ + + def __init__(self, evo_data, client, obj_ref): + """Initialize the evohome Controller (hub).""" + super().__init__(evo_data, client, obj_ref) + + self._id = obj_ref.systemId + self._name = '_{}'.format(obj_ref.location.name) + self._icon = "mdi:thermostat" + self._type = EVO_PARENT + + self._config = evo_data['config'][GWS][0][TCS][0] + self._status = evo_data['status'] + self._timers['statusUpdated'] = datetime.min + + self._operation_list = TCS_OP_LIST + self._supported_features = \ + SUPPORT_OPERATION_MODE | \ + SUPPORT_AWAY_MODE + + @property + def device_state_attributes(self): + """Return the device state attributes of the evohome Controller. + + This is state data that is not available otherwise, due to the + restrictions placed upon ClimateDevice properties, etc. by HA. + """ + status = dict(self._status) + + if 'zones' in status: + del status['zones'] + if 'dhw' in status: + del status['dhw'] + + return {'status': status} + + @property + def current_operation(self): + """Return the current operating mode of the evohome Controller.""" + return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) + + @property + def min_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a minimum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 5 + + @property + def max_temp(self): + """Return the minimum target temperature of a evohome Controller. + + Although evohome Controllers do not have a maximum target temp, one is + expected by the HA schema; the default for an evohome HR92 is used. + """ + return 35 @property def target_temperature(self): - """Return the average target temperature of the Heating/DHW zones.""" + """Return the average target temperature of the Heating/DHW zones. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ temps = [zone['setpointStatus']['targetHeatTemperature'] for zone in self._status['zones']] @@ -211,7 +482,11 @@ def target_temperature(self): @property def current_temperature(self): - """Return the average current temperature of the Heating/DHW zones.""" + """Return the average current temperature of the Heating/DHW zones. + + Although evohome Controllers do not have a target temp, one is + expected by the HA schema. + """ tmp_list = [x for x in self._status['zones'] if x['temperatureStatus']['isAvailable'] is True] temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] @@ -220,54 +495,36 @@ def current_temperature(self): return avg_temp @property - def temperature_unit(self): - """Return the temperature unit to use in the frontend UI.""" - return TEMP_CELSIUS - - @property - def precision(self): - """Return the temperature precision to use in the frontend UI.""" - return PRECISION_TENTHS - - @property - def min_temp(self): - """Return the minimum target temp (setpoint) of a evohome entity.""" - return MIN_TEMP - - @property - def max_temp(self): - """Return the maximum target temp (setpoint) of a evohome entity.""" - return MAX_TEMP - - @property - def is_on(self): - """Return true as evohome controllers are always on. + def is_on(self) -> bool: + """Return True as evohome Controllers are always on. - Operating modes can include 'HeatingOff', but (for example) DHW would - remain on. + For example, evohome Controllers have a 'HeatingOff' mode, but even + then the DHW would remain on. """ return True @property - def is_away_mode_on(self): - """Return true if away mode is on.""" + def is_away_mode_on(self) -> bool: + """Return True if away mode is on.""" return self._status['systemModeStatus']['mode'] == EVO_AWAY def turn_away_mode_on(self): - """Turn away mode on.""" + """Turn away mode on. + + The evohome Controller will not remember is previous operating mode. + """ self._set_operation_mode(EVO_AWAY) def turn_away_mode_off(self): - """Turn away mode off.""" + """Turn away mode off. + + The evohome Controller can not recall its previous operating mode (as + intimated by the HA schema), so this method is achieved by setting the + Controller's mode back to Auto. + """ self._set_operation_mode(EVO_AUTO) def _set_operation_mode(self, operation_mode): - # Set new target operation mode for the TCS. - _LOGGER.debug( - "_set_operation_mode(): API call [1 request(s)]: " - "tcs._set_status(%s)...", - operation_mode - ) try: self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access except HTTPError as err: @@ -279,93 +536,45 @@ def set_operation_mode(self, operation_mode): Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away' mode is needed, it can be enabled via turn_away_mode_on method. """ - self._set_operation_mode(HA_STATE_TO_EVO.get(operation_mode)) + self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode)) - def _update_state_data(self, evo_data): - client = evo_data['client'] - loc_idx = evo_data['params'][CONF_LOCATION_IDX] - - _LOGGER.debug( - "_update_state_data(): API call [1 request(s)]: " - "client.locations[loc_idx].status()..." - ) - - try: - evo_data['status'].update( - client.locations[loc_idx].status()[GWS][0][TCS][0]) - except HTTPError as err: # check if we've exceeded the api rate limit - self._handle_requests_exceptions(err) - else: - evo_data['timers']['statusUpdated'] = datetime.now() - - _LOGGER.debug( - "_update_state_data(): evo_data['status'] = %s", - evo_data['status'] - ) + @property + def should_poll(self) -> bool: + """Return True as the evohome Controller should always be polled.""" + return True def update(self): - """Get the latest state data of the installation. + """Get the latest state data of the entire evohome Location. - This includes state data for the Controller and its child devices, such - as the operating_mode of the Controller and the current_temperature - of its children. - - This is not asyncio-friendly due to the underlying client api. + This includes state data for the Controller and all its child devices, + such as the operating mode of the Controller and the current temp of + its children (e.g. Zones, DHW controller). """ - evo_data = self.hass.data[DATA_EVOHOME] - + # should the latest evohome state data be retreived this cycle? timeout = datetime.now() + timedelta(seconds=55) expired = timeout > self._timers['statusUpdated'] + \ - timedelta(seconds=evo_data['params'][CONF_SCAN_INTERVAL]) + self._params[CONF_SCAN_INTERVAL] if not expired: return - was_available = self._available or \ - self._timers['statusUpdated'] == datetime.min - - self._update_state_data(evo_data) - self._status = evo_data['status'] - - if _LOGGER.isEnabledFor(logging.DEBUG): - tmp_dict = dict(self._status) - if 'zones' in tmp_dict: - tmp_dict['zones'] = '...' - if 'dhw' in tmp_dict: - tmp_dict['dhw'] = '...' - - _LOGGER.debug( - "update(%s), self._status = %s", - self._id + " [" + self._name + "]", - tmp_dict - ) - - no_recent_updates = self._timers['statusUpdated'] < datetime.now() - \ - timedelta(seconds=self._params[CONF_SCAN_INTERVAL] * 3.1) - - if no_recent_updates: - self._available = False - debug_code = EVO_DEBUG_NO_RECENT_UPDATES - - elif not self._status: - # unavailable because no status (but how? other than at startup?) - self._available = False - debug_code = EVO_DEBUG_NO_STATUS + # Retreive the latest state data via the client api + loc_idx = self._params[CONF_LOCATION_IDX] + try: + self._status.update( + self._client.locations[loc_idx].status()[GWS][0][TCS][0]) + except HTTPError as err: # check if we've exceeded the api rate limit + self._handle_requests_exceptions(err) else: + self._timers['statusUpdated'] = datetime.now() self._available = True - if not self._available and was_available: - # only warn if available went from True to False - _LOGGER.warning( - "The entity, %s, has become unavailable, debug code is: %s", - self._id + " [" + self._name + "]", - debug_code - ) + _LOGGER.debug( + "_update_state_data(): self._status = %s", + self._status + ) - elif self._available and not was_available: - # this isn't the first re-available (e.g. _after_ STARTUP) - _LOGGER.debug( - "The entity, %s, has become available", - self._id + " [" + self._name + "]" - ) + # inform the child devices that state data has been updated + pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD} + dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt) diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index 212c4265d8a79c..ffab50c989d708 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import ( async_track_state_change, async_track_time_interval) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, precision)]) -class GenericThermostat(ClimateDevice): +class GenericThermostat(ClimateDevice, RestoreEntity): """Representation of a Generic Thermostat device.""" def __init__(self, hass, name, heater_entity_id, sensor_entity_id, @@ -155,8 +155,9 @@ def __init__(self, hass, name, heater_entity_id, sensor_entity_id, async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() # Check If we have an old state - old_state = await async_get_last_state(self.hass, self.entity_id) + old_state = await self.async_get_last_state() if old_state is not None: # If we have no initial temperature, restore if self._target_temp is None: diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index c445a495073000..e0f104a84b1955 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, CONF_REGION) -REQUIREMENTS = ['evohomeclient==0.2.7', 'somecomfort==0.5.2'] +REQUIREMENTS = ['evohomeclient==0.2.8', 'somecomfort==0.5.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/mill.py b/homeassistant/components/climate/mill.py index 6be4fe183b7f80..5ea48614f6b091 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.8'] +REQUIREMENTS = ['millheater==0.2.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index b107710fea5476..098ff2867daa85 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -22,7 +22,8 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_QOS, CONF_RETAIN, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, - MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate) + MQTT_BASE_PLATFORM_SCHEMA, MqttAvailability, MqttDiscoveryUpdate, + 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 @@ -77,6 +78,18 @@ CONF_MAX_TEMP = 'max_temp' CONF_TEMP_STEP = 'temp_step' +TEMPLATE_KEYS = ( + CONF_POWER_STATE_TEMPLATE, + CONF_MODE_STATE_TEMPLATE, + CONF_TEMPERATURE_STATE_TEMPLATE, + CONF_FAN_MODE_STATE_TEMPLATE, + CONF_SWING_MODE_STATE_TEMPLATE, + CONF_AWAY_MODE_STATE_TEMPLATE, + CONF_HOLD_STATE_TEMPLATE, + CONF_AUX_STATE_TEMPLATE, + CONF_CURRENT_TEMPERATURE_TEMPLATE +) + SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, @@ -153,69 +166,10 @@ async def async_discover(discovery_payload): async def _async_setup_entity(hass, config, async_add_entities, discovery_hash=None): """Set up the MQTT climate devices.""" - template_keys = ( - CONF_POWER_STATE_TEMPLATE, - CONF_MODE_STATE_TEMPLATE, - CONF_TEMPERATURE_STATE_TEMPLATE, - CONF_FAN_MODE_STATE_TEMPLATE, - CONF_SWING_MODE_STATE_TEMPLATE, - CONF_AWAY_MODE_STATE_TEMPLATE, - CONF_HOLD_STATE_TEMPLATE, - CONF_AUX_STATE_TEMPLATE, - CONF_CURRENT_TEMPERATURE_TEMPLATE - ) - value_templates = {} - if CONF_VALUE_TEMPLATE in config: - value_template = config.get(CONF_VALUE_TEMPLATE) - value_template.hass = hass - value_templates = {key: value_template for key in template_keys} - for key in template_keys & config.keys(): - value_templates[key] = config.get(key) - value_templates[key].hass = hass - async_add_entities([ MqttClimate( hass, - config.get(CONF_NAME), - { - key: config.get(key) for key in ( - CONF_POWER_COMMAND_TOPIC, - CONF_MODE_COMMAND_TOPIC, - CONF_TEMPERATURE_COMMAND_TOPIC, - CONF_FAN_MODE_COMMAND_TOPIC, - CONF_SWING_MODE_COMMAND_TOPIC, - CONF_AWAY_MODE_COMMAND_TOPIC, - CONF_HOLD_COMMAND_TOPIC, - CONF_AUX_COMMAND_TOPIC, - CONF_POWER_STATE_TOPIC, - CONF_MODE_STATE_TOPIC, - CONF_TEMPERATURE_STATE_TOPIC, - CONF_FAN_MODE_STATE_TOPIC, - CONF_SWING_MODE_STATE_TOPIC, - CONF_AWAY_MODE_STATE_TOPIC, - CONF_HOLD_STATE_TOPIC, - CONF_AUX_STATE_TOPIC, - CONF_CURRENT_TEMPERATURE_TOPIC - ) - }, - value_templates, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_MODE_LIST), - config.get(CONF_FAN_MODE_LIST), - config.get(CONF_SWING_MODE_LIST), - config.get(CONF_INITIAL), - False, None, SPEED_LOW, - STATE_OFF, STATE_OFF, False, - config.get(CONF_SEND_IF_OFF), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_MIN_TEMP), - config.get(CONF_MAX_TEMP), - config.get(CONF_TEMP_STEP), + config, discovery_hash, )]) @@ -223,54 +177,103 @@ async def _async_setup_entity(hass, config, async_add_entities, class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """Representation of an MQTT climate device.""" - def __init__(self, hass, name, topic, value_templates, qos, retain, - mode_list, fan_mode_list, swing_mode_list, - target_temperature, away, hold, current_fan_mode, - current_swing_mode, current_operation, aux, send_if_off, - payload_on, payload_off, availability_topic, - payload_available, payload_not_available, - min_temp, max_temp, temp_step, discovery_hash): + def __init__(self, hass, config, discovery_hash): """Initialize the climate device.""" + self._config = config + self._sub_state = None + + self.hass = hass + self._topic = None + self._value_templates = None + self._target_temperature = None + self._current_fan_mode = None + self._current_operation = None + self._current_swing_mode = None + self._unit_of_measurement = hass.config.units.temperature_unit + self._away = False + self._hold = None + self._current_temperature = None + self._aux = False + + 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) + qos = config.get(CONF_QOS) + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self.hass = hass - self._name = name - self._topic = topic - self._value_templates = value_templates - self._qos = qos - self._retain = retain + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Handle being added to home assistant.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + 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._topic = { + key: config.get(key) for key in ( + CONF_POWER_COMMAND_TOPIC, + CONF_MODE_COMMAND_TOPIC, + CONF_TEMPERATURE_COMMAND_TOPIC, + CONF_FAN_MODE_COMMAND_TOPIC, + CONF_SWING_MODE_COMMAND_TOPIC, + CONF_AWAY_MODE_COMMAND_TOPIC, + CONF_HOLD_COMMAND_TOPIC, + CONF_AUX_COMMAND_TOPIC, + CONF_POWER_STATE_TOPIC, + CONF_MODE_STATE_TOPIC, + CONF_TEMPERATURE_STATE_TOPIC, + CONF_FAN_MODE_STATE_TOPIC, + CONF_SWING_MODE_STATE_TOPIC, + CONF_AWAY_MODE_STATE_TOPIC, + CONF_HOLD_STATE_TOPIC, + CONF_AUX_STATE_TOPIC, + CONF_CURRENT_TEMPERATURE_TOPIC + ) + } + # set to None in non-optimistic mode self._target_temperature = self._current_fan_mode = \ self._current_operation = self._current_swing_mode = None if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None: - self._target_temperature = target_temperature - self._unit_of_measurement = hass.config.units.temperature_unit - self._away = away - self._hold = hold - self._current_temperature = None + self._target_temperature = config.get(CONF_INITIAL) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: - self._current_fan_mode = current_fan_mode - if self._topic[CONF_MODE_STATE_TOPIC] is None: - self._current_operation = current_operation - self._aux = aux + self._current_fan_mode = SPEED_LOW if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: - self._current_swing_mode = current_swing_mode - self._fan_list = fan_mode_list - self._operation_list = mode_list - self._swing_list = swing_mode_list - self._target_temperature_step = temp_step - self._send_if_off = send_if_off - self._payload_on = payload_on - self._payload_off = payload_off - self._min_temp = min_temp - self._max_temp = max_temp - self._discovery_hash = discovery_hash + self._current_swing_mode = STATE_OFF + if self._topic[CONF_MODE_STATE_TOPIC] is None: + self._current_operation = STATE_OFF + self._away = False + self._hold = None + self._aux = False + + value_templates = {} + if CONF_VALUE_TEMPLATE in config: + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = self.hass + value_templates = {key: value_template for key in TEMPLATE_KEYS} + for key in TEMPLATE_KEYS & config.keys(): + value_templates[key] = config.get(key) + value_templates[key].hass = self.hass + self._value_templates = value_templates - async def async_added_to_hass(self): - """Handle being added to home assistant.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} + qos = self._config.get(CONF_QOS) @callback def handle_current_temp_received(topic, payload, qos): @@ -287,9 +290,10 @@ def handle_current_temp_received(topic, payload, qos): _LOGGER.error("Could not parse temperature from %s", payload) if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], - handle_current_temp_received, self._qos) + topics[CONF_CURRENT_TEMPERATURE_TOPIC] = { + 'topic': self._topic[CONF_CURRENT_TEMPERATURE_TOPIC], + 'msg_callback': handle_current_temp_received, + 'qos': qos} @callback def handle_mode_received(topic, payload, qos): @@ -298,16 +302,17 @@ def handle_mode_received(topic, payload, qos): payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._operation_list: + if payload not in self._config.get(CONF_MODE_LIST): _LOGGER.error("Invalid mode: %s", payload) else: self._current_operation = payload self.async_schedule_update_ha_state() if self._topic[CONF_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_MODE_STATE_TOPIC], - handle_mode_received, self._qos) + topics[CONF_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_MODE_STATE_TOPIC], + 'msg_callback': handle_mode_received, + 'qos': qos} @callback def handle_temperature_received(topic, payload, qos): @@ -324,9 +329,10 @@ def handle_temperature_received(topic, payload, qos): _LOGGER.error("Could not parse temperature from %s", payload) if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC], - handle_temperature_received, self._qos) + topics[CONF_TEMPERATURE_STATE_TOPIC] = { + 'topic': self._topic[CONF_TEMPERATURE_STATE_TOPIC], + 'msg_callback': handle_temperature_received, + 'qos': qos} @callback def handle_fan_mode_received(topic, payload, qos): @@ -336,16 +342,17 @@ def handle_fan_mode_received(topic, payload, qos): self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._fan_list: + if payload not in self._config.get(CONF_FAN_MODE_LIST): _LOGGER.error("Invalid fan mode: %s", payload) else: self._current_fan_mode = payload self.async_schedule_update_ha_state() if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC], - handle_fan_mode_received, self._qos) + topics[CONF_FAN_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_FAN_MODE_STATE_TOPIC], + 'msg_callback': handle_fan_mode_received, + 'qos': qos} @callback def handle_swing_mode_received(topic, payload, qos): @@ -355,32 +362,35 @@ def handle_swing_mode_received(topic, payload, qos): self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) - if payload not in self._swing_list: + if payload not in self._config.get(CONF_SWING_MODE_LIST): _LOGGER.error("Invalid swing mode: %s", payload) else: self._current_swing_mode = payload self.async_schedule_update_ha_state() if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC], - handle_swing_mode_received, self._qos) + topics[CONF_SWING_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_SWING_MODE_STATE_TOPIC], + 'msg_callback': handle_swing_mode_received, + 'qos': qos} @callback def handle_away_mode_received(topic, payload, qos): """Handle receiving away mode via MQTT.""" + payload_on = self._config.get(CONF_PAYLOAD_ON) + payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_AWAY_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) if payload == "True": - payload = self._payload_on + payload = payload_on elif payload == "False": - payload = self._payload_off + payload = payload_off - if payload == self._payload_on: + if payload == payload_on: self._away = True - elif payload == self._payload_off: + elif payload == payload_off: self._away = False else: _LOGGER.error("Invalid away mode: %s", payload) @@ -388,24 +398,27 @@ def handle_away_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC], - handle_away_mode_received, self._qos) + topics[CONF_AWAY_MODE_STATE_TOPIC] = { + 'topic': self._topic[CONF_AWAY_MODE_STATE_TOPIC], + 'msg_callback': handle_away_mode_received, + 'qos': qos} @callback def handle_aux_mode_received(topic, payload, qos): """Handle receiving aux mode via MQTT.""" + payload_on = self._config.get(CONF_PAYLOAD_ON) + payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AUX_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_AUX_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) if payload == "True": - payload = self._payload_on + payload = payload_on elif payload == "False": - payload = self._payload_off + payload = payload_off - if payload == self._payload_on: + if payload == payload_on: self._aux = True - elif payload == self._payload_off: + elif payload == payload_off: self._aux = False else: _LOGGER.error("Invalid aux mode: %s", payload) @@ -413,9 +426,10 @@ def handle_aux_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_AUX_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_AUX_STATE_TOPIC], - handle_aux_mode_received, self._qos) + topics[CONF_AUX_STATE_TOPIC] = { + 'topic': self._topic[CONF_AUX_STATE_TOPIC], + 'msg_callback': handle_aux_mode_received, + 'qos': qos} @callback def handle_hold_mode_received(topic, payload, qos): @@ -428,9 +442,19 @@ def handle_hold_mode_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_HOLD_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_HOLD_STATE_TOPIC], - handle_hold_mode_received, self._qos) + topics[CONF_HOLD_STATE_TOPIC] = { + 'topic': self._topic[CONF_HOLD_STATE_TOPIC], + 'msg_callback': handle_hold_mode_received, + 'qos': qos} + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + 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): @@ -440,7 +464,7 @@ def should_poll(self): @property def name(self): """Return the name of the climate device.""" - return self._name + return self._config.get(CONF_NAME) @property def temperature_unit(self): @@ -465,12 +489,12 @@ def current_operation(self): @property def operation_list(self): """Return the list of available operation modes.""" - return self._operation_list + return self._config.get(CONF_MODE_LIST) @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._target_temperature_step + return self._config.get(CONF_TEMP_STEP) @property def is_away_mode_on(self): @@ -495,7 +519,7 @@ def current_fan_mode(self): @property def fan_list(self): """Return the list of available fan modes.""" - return self._fan_list + return self._config.get(CONF_FAN_MODE_LIST) async def async_set_temperature(self, **kwargs): """Set new target temperatures.""" @@ -508,19 +532,23 @@ async def async_set_temperature(self, **kwargs): # optimistic mode self._target_temperature = kwargs.get(ATTR_TEMPERATURE) - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC], - kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain) + kwargs.get(ATTR_TEMPERATURE), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) self.async_schedule_update_ha_state() async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC], - swing_mode, self._qos, self._retain) + swing_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: self._current_swing_mode = swing_mode @@ -528,10 +556,12 @@ async def async_set_swing_mode(self, swing_mode): async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" - if self._send_if_off or self._current_operation != STATE_OFF: + if (self._config.get(CONF_SEND_IF_OFF) or + self._current_operation != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC], - fan_mode, self._qos, self._retain) + fan_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = fan_mode @@ -539,22 +569,24 @@ async def async_set_fan_mode(self, fan_mode): async def async_set_operation_mode(self, operation_mode) -> None: """Set new operation mode.""" + qos = self._config.get(CONF_QOS) + retain = self._config.get(CONF_RETAIN) if self._topic[CONF_POWER_COMMAND_TOPIC] is not None: if (self._current_operation == STATE_OFF and operation_mode != STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), qos, retain) elif (self._current_operation != STATE_OFF and operation_mode == STATE_OFF): mqtt.async_publish( self.hass, self._topic[CONF_POWER_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), qos, retain) if self._topic[CONF_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish( self.hass, self._topic[CONF_MODE_COMMAND_TOPIC], - operation_mode, self._qos, self._retain) + operation_mode, qos, retain) if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = operation_mode @@ -568,14 +600,16 @@ def current_swing_mode(self): @property def swing_list(self): """List of available swing modes.""" - return self._swing_list + return self._config.get(CONF_SWING_MODE_LIST) async def async_turn_away_mode_on(self): """Turn away mode on.""" if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = True @@ -586,7 +620,9 @@ async def async_turn_away_mode_off(self): if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AWAY_MODE_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = False @@ -597,7 +633,8 @@ async def async_set_hold_mode(self, hold_mode): if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_HOLD_COMMAND_TOPIC], - hold_mode, self._qos, self._retain) + hold_mode, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_HOLD_STATE_TOPIC] is None: self._hold = hold_mode @@ -607,7 +644,9 @@ async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], - self._payload_on, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = True @@ -617,7 +656,9 @@ async def async_turn_aux_heat_off(self): """Turn auxiliary heater off.""" if self._topic[CONF_AUX_COMMAND_TOPIC] is not None: mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC], - self._payload_off, self._qos, self._retain) + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = False @@ -661,9 +702,9 @@ def supported_features(self): @property def min_temp(self): """Return the minimum temperature.""" - return self._min_temp + return self._config.get(CONF_MIN_TEMP) @property def max_temp(self): """Return the maximum temperature.""" - return self._max_temp + return self._config.get(CONF_MAX_TEMP) diff --git a/homeassistant/components/climate/toon.py b/homeassistant/components/climate/toon.py index 5972ff52a8b2bf..022a509ce06bdb 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -15,6 +15,14 @@ SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE +HA_TOON = { + STATE_AUTO: 'Comfort', + STATE_HEAT: 'Home', + STATE_ECO: 'Away', + STATE_COOL: 'Sleep', +} +TOON_HA = {value: key for key, value in HA_TOON.items()} + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Toon climate device.""" @@ -58,8 +66,7 @@ def temperature_unit(self): @property def current_operation(self): """Return current operation i.e. comfort, home, away.""" - state = self.thermos.get_data('state') - return state + return TOON_HA.get(self.thermos.get_data('state')) @property def operation_list(self): @@ -83,14 +90,7 @@ def set_temperature(self, **kwargs): def set_operation_mode(self, operation_mode): """Set new operation mode.""" - toonlib_values = { - STATE_AUTO: 'Comfort', - STATE_HEAT: 'Home', - STATE_ECO: 'Away', - STATE_COOL: 'Sleep', - } - - self.thermos.set_state(toonlib_values[operation_mode]) + self.thermos.set_state(HA_TOON[operation_mode]) def update(self): """Update local state.""" diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index ba5621b1f8dd92..fd5b413043e38d 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,7 +20,7 @@ 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, prefs +from . import http_api, iot, auth_api, prefs, cloudhooks from .const import CONFIG_DIR, DOMAIN, SERVERS REQUIREMENTS = ['warrant==0.6.1'] @@ -37,6 +37,7 @@ CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -78,6 +79,7 @@ vol.Optional(CONF_RELAYER): str, vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -113,7 +115,7 @@ class Cloud: def __init__(self, hass, mode, alexa, google_actions, cognito_client_id=None, user_pool_id=None, region=None, relayer=None, google_actions_sync_url=None, - subscription_info_url=None): + subscription_info_url=None, cloudhook_create_url=None): """Create an instance of Cloud.""" self.hass = hass self.mode = mode @@ -125,6 +127,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.access_token = None self.refresh_token = None self.iot = iot.CloudIoT(self) + self.cloudhooks = cloudhooks.Cloudhooks(self) if mode == MODE_DEV: self.cognito_client_id = cognito_client_id @@ -133,6 +136,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.relayer = relayer self.google_actions_sync_url = google_actions_sync_url self.subscription_info_url = subscription_info_url + self.cloudhook_create_url = cloudhook_create_url else: info = SERVERS[mode] @@ -143,6 +147,7 @@ def __init__(self, hass, mode, alexa, google_actions, self.relayer = info['relayer'] self.google_actions_sync_url = info['google_actions_sync_url'] self.subscription_info_url = info['subscription_info_url'] + self.cloudhook_create_url = info['cloudhook_create_url'] @property def is_logged_in(self): @@ -247,8 +252,7 @@ def load_config(): return json.loads(file.read()) info = await self.hass.async_add_job(load_config) - - await self.prefs.async_initialize(bool(info)) + await self.prefs.async_initialize() if info is None: return diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py new file mode 100644 index 00000000000000..c62768cc5145a7 --- /dev/null +++ b/homeassistant/components/cloud/cloud_api.py @@ -0,0 +1,42 @@ +"""Cloud APIs.""" +from functools import wraps +import logging + +from . import auth_api + +_LOGGER = logging.getLogger(__name__) + + +def _check_token(func): + """Decorate a function to verify valid token.""" + @wraps(func) + async def check_token(cloud, *args): + """Validate token, then call func.""" + await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) + return await func(cloud, *args) + + return check_token + + +def _log_response(func): + """Decorate a function to log bad responses.""" + @wraps(func) + async def log_response(*args): + """Log response if it's bad.""" + resp = await func(*args) + meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning + meth('Fetched %s (%s)', resp.url, resp.status) + return resp + + return log_response + + +@_check_token +@_log_response +async def async_create_cloudhook(cloud): + """Create a cloudhook.""" + websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() + return await websession.post( + cloud.cloudhook_create_url, headers={ + 'authorization': cloud.id_token + }) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py new file mode 100644 index 00000000000000..3c638d2916637e --- /dev/null +++ b/homeassistant/components/cloud/cloudhooks.py @@ -0,0 +1,66 @@ +"""Manage cloud cloudhooks.""" +import async_timeout + +from . import cloud_api + + +class Cloudhooks: + """Class to help manage cloudhooks.""" + + def __init__(self, cloud): + """Initialize cloudhooks.""" + self.cloud = cloud + self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) + + async def async_publish_cloudhooks(self): + """Inform the Relayer of the cloudhooks that we support.""" + cloudhooks = self.cloud.prefs.cloudhooks + await self.cloud.iot.async_send_message('webhook-register', { + 'cloudhook_ids': [info['cloudhook_id'] for info + in cloudhooks.values()] + }, expect_answer=False) + + async def async_create(self, webhook_id): + """Create a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id in cloudhooks: + raise ValueError('Hook is already enabled for the cloud.') + + if not self.cloud.iot.connected: + raise ValueError("Cloud is not connected") + + # Create cloud hook + with async_timeout.timeout(10): + resp = await cloud_api.async_create_cloudhook(self.cloud) + + data = await resp.json() + cloudhook_id = data['cloudhook_id'] + cloudhook_url = data['url'] + + # Store hook + cloudhooks = dict(cloudhooks) + hook = cloudhooks[webhook_id] = { + 'webhook_id': webhook_id, + 'cloudhook_id': cloudhook_id, + 'cloudhook_url': cloudhook_url + } + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() + + return hook + + async def async_delete(self, webhook_id): + """Delete a cloud webhook.""" + cloudhooks = self.cloud.prefs.cloudhooks + + if webhook_id not in cloudhooks: + raise ValueError('Hook is not enabled for the cloud.') + + # Remove hook + cloudhooks = dict(cloudhooks) + cloudhooks.pop(webhook_id) + await self.cloud.prefs.async_update(cloudhooks=cloudhooks) + + await self.async_publish_cloudhooks() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index abc72da796cf66..a5019efaa8eecc 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -6,6 +6,7 @@ PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' +PREF_CLOUDHOOKS = 'cloudhooks' SERVERS = { 'production': { @@ -16,7 +17,8 @@ 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' 'amazonaws.com/prod/smart_home_sync'), 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' - 'subscription_info') + 'subscription_info'), + 'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' } } diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 7b509f4eae25b7..03a77c08d4b683 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ from functools import wraps import logging +import aiohttp import async_timeout import voluptuous as vol @@ -44,6 +45,20 @@ }) +WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create' +SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_CREATE, + vol.Required('webhook_id'): str +}) + + +WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete' +SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_HOOK_DELETE, + vol.Required('webhook_id'): str +}) + + async def async_setup(hass): """Initialize the HTTP API.""" hass.components.websocket_api.async_register_command( @@ -58,6 +73,14 @@ async def async_setup(hass): WS_TYPE_UPDATE_PREFS, websocket_update_prefs, SCHEMA_WS_UPDATE_PREFS ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_CREATE, websocket_hook_create, + SCHEMA_WS_HOOK_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_HOOK_DELETE, websocket_hook_delete, + SCHEMA_WS_HOOK_DELETE + ) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -76,7 +99,7 @@ async def async_setup(hass): def _handle_cloud_errors(handler): - """Handle auth errors.""" + """Webview decorator to handle auth errors.""" @wraps(handler) async def error_handler(view, request, *args, **kwargs): """Handle exceptions that raise from the wrapped request handler.""" @@ -240,17 +263,49 @@ def websocket_cloud_status(hass, connection, msg): websocket_api.result_message(msg['id'], _account_data(cloud))) +def _require_cloud_login(handler): + """Websocket decorator that requires cloud to be logged in.""" + @wraps(handler) + def with_cloud_auth(hass, connection, msg): + """Require to be logged into the cloud.""" + cloud = hass.data[DOMAIN] + if not cloud.is_logged_in: + connection.send_message(websocket_api.error_message( + msg['id'], 'not_logged_in', + 'You need to be logged in to the cloud.')) + return + + handler(hass, connection, msg) + + return with_cloud_auth + + +def _handle_aiohttp_errors(handler): + """Websocket decorator that handlers aiohttp errors. + + Can only wrap async handlers. + """ + @wraps(handler) + async def with_error_handling(hass, connection, msg): + """Handle aiohttp errors.""" + try: + await handler(hass, connection, msg) + except asyncio.TimeoutError: + connection.send_message(websocket_api.error_message( + msg['id'], 'timeout', 'Command timed out.')) + except aiohttp.ClientError: + connection.send_message(websocket_api.error_message( + msg['id'], 'unknown', 'Error making request.')) + + return with_error_handling + + +@_require_cloud_login @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): response = await cloud.fetch_subscription_info() @@ -277,24 +332,37 @@ async def websocket_subscription(hass, connection, msg): connection.send_message(websocket_api.result_message(msg['id'], data)) +@_require_cloud_login @websocket_api.async_response async def websocket_update_prefs(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - if not cloud.is_logged_in: - connection.send_message(websocket_api.error_message( - msg['id'], 'not_logged_in', - 'You need to be logged in to the cloud.')) - return - changes = dict(msg) changes.pop('id') changes.pop('type') await cloud.prefs.async_update(**changes) - connection.send_message(websocket_api.result_message( - msg['id'], {'success': True})) + connection.send_message(websocket_api.result_message(msg['id'])) + + +@_require_cloud_login +@websocket_api.async_response +@_handle_aiohttp_errors +async def websocket_hook_create(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'], hook)) + + +@_require_cloud_login +@websocket_api.async_response +async def websocket_hook_delete(hass, connection, msg): + """Handle request for account info.""" + cloud = hass.data[DOMAIN] + await cloud.cloudhooks.async_delete(msg['webhook_id']) + connection.send_message(websocket_api.result_message(msg['id'])) def _account_data(cloud): diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index c5657ae97292f2..7d633a4b2ac7a3 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -2,13 +2,16 @@ import asyncio import logging import pprint +import uuid from aiohttp import hdrs, client_exceptions, WSMsgType from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.components.alexa import smart_home as alexa from homeassistant.components.google_assistant import smart_home as ga +from homeassistant.core import callback from homeassistant.util.decorator import Registry +from homeassistant.util.aiohttp import MockRequest, serialize_response from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL @@ -25,6 +28,19 @@ class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" +class NotConnected(Exception): + """Exception raised when trying to handle unknown handler.""" + + +class ErrorMessage(Exception): + """Exception raised when there was error handling message in the cloud.""" + + def __init__(self, error): + """Initialize Error Message.""" + super().__init__(self, "Error in Cloud") + self.error = error + + class CloudIoT: """Class to manage the IoT connection.""" @@ -41,6 +57,19 @@ def __init__(self, cloud): self.tries = 0 # Current state of the connection self.state = STATE_DISCONNECTED + # Local code waiting for a response + self._response_handler = {} + self._on_connect = [] + + @callback + def register_on_connect(self, on_connect_cb): + """Register an async on_connect callback.""" + self._on_connect.append(on_connect_cb) + + @property + def connected(self): + """Return if we're currently connected.""" + return self.state == STATE_CONNECTED @asyncio.coroutine def connect(self): @@ -91,6 +120,30 @@ def _handle_hass_stop(event): if remove_hass_stop_listener is not None: remove_hass_stop_listener() + async def async_send_message(self, handler, payload, + expect_answer=True): + """Send a message.""" + if self.state != STATE_CONNECTED: + raise NotConnected + + msgid = uuid.uuid4().hex + + if expect_answer: + fut = self._response_handler[msgid] = asyncio.Future() + + message = { + 'msgid': msgid, + 'handler': handler, + 'payload': payload, + } + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Publishing message:\n%s\n", + pprint.pformat(message)) + await self.client.send_json(message) + + if expect_answer: + return await fut + @asyncio.coroutine def _handle_connection(self): """Connect to the IoT broker.""" @@ -134,6 +187,9 @@ def _handle_connection(self): _LOGGER.info("Connected") self.state = STATE_CONNECTED + if self._on_connect: + yield from asyncio.wait([cb() for cb in self._on_connect]) + while not client.closed: msg = yield from client.receive() @@ -159,6 +215,17 @@ def _handle_connection(self): _LOGGER.debug("Received message:\n%s\n", pprint.pformat(msg)) + response_handler = self._response_handler.pop(msg['msgid'], + None) + + if response_handler is not None: + if 'payload' in msg: + response_handler.set_result(msg["payload"]) + else: + response_handler.set_exception( + ErrorMessage(msg['error'])) + continue + response = { 'msgid': msg['msgid'], } @@ -257,3 +324,43 @@ def async_handle_cloud(hass, cloud, payload): payload['reason']) else: _LOGGER.warning("Received unknown cloud action: %s", action) + + +@HANDLERS.register('webhook') +async def async_handle_webhook(hass, cloud, payload): + """Handle an incoming IoT message for cloud webhooks.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in cloud.prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = serialize_response(response) + body = response_dict.get('body') + if body: + body = body.decode('utf-8') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 7e1ec6a02328d8..32362df2fa98f8 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,7 @@ """Preference management for cloud.""" from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK) + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -16,28 +16,29 @@ def __init__(self, hass): self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None - async def async_initialize(self, logged_in): + async def async_initialize(self): """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_ENABLE_ALEXA: True, + PREF_ENABLE_GOOGLE: True, PREF_GOOGLE_ALLOW_UNLOCK: False, + PREF_CLOUDHOOKS: {} } - await self._store.async_save(prefs) self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF): + alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, + cloudhooks=_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), + (PREF_CLOUDHOOKS, cloudhooks), ): if value is not _UNDEF: self._prefs[key] = value @@ -62,3 +63,8 @@ def google_enabled(self): def google_allow_unlock(self): """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_ALLOW_UNLOCK, False) + + @property + def cloudhooks(self): + """Return the published cloud webhooks.""" + return self._prefs.get(PREF_CLOUDHOOKS, {}) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index f2cfff1f34209a..4154ca337a3859 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,6 +14,8 @@ DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ( + 'auth', + 'auth_provider_homeassistant', 'automation', 'config_entries', 'core', @@ -58,10 +60,6 @@ def component_loaded(event): tasks = [setup_panel(panel_name) for panel_name in SECTIONS] - if hass.auth.active: - tasks.append(setup_panel('auth')) - tasks.append(setup_panel('auth_provider_homeassistant')) - for panel_name in ON_DEMAND: if panel_name in hass.config.components: tasks.append(setup_panel(panel_name)) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index d67c93c0d6ef9c..228870489a2d68 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -10,9 +10,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -86,7 +85,7 @@ async def async_setup(hass, config): return True -class Counter(Entity): +class Counter(RestoreEntity): """Representation of a counter.""" def __init__(self, object_id, name, initial, restore, step, icon): @@ -128,10 +127,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" + await super().async_added_to_hass() # __init__ will set self._state to self._initial, only override # if needed. if self._restore: - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if state is not None: self._state = int(state.state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index f51cca8a276dee..55df204f2757af 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/cover.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -24,7 +23,7 @@ ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic, - 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 @@ -130,7 +129,7 @@ def validate_options(value): async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT cover through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -138,7 +137,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -146,112 +145,78 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT Cover.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - set_position_template = config.get(CONF_SET_POSITION_TEMPLATE) - if set_position_template is not None: - set_position_template.hass = hass - - async_add_entities([MqttCover( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_GET_POSITION_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_TILT_COMMAND_TOPIC), - config.get(CONF_TILT_STATUS_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_STATE_OPEN), - config.get(CONF_STATE_CLOSED), - config.get(CONF_POSITION_OPEN), - config.get(CONF_POSITION_CLOSED), - config.get(CONF_PAYLOAD_OPEN), - config.get(CONF_PAYLOAD_CLOSE), - config.get(CONF_PAYLOAD_STOP), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_OPTIMISTIC), - value_template, - config.get(CONF_TILT_OPEN_POSITION), - config.get(CONF_TILT_CLOSED_POSITION), - config.get(CONF_TILT_MIN), - config.get(CONF_TILT_MAX), - config.get(CONF_TILT_STATE_OPTIMISTIC), - config.get(CONF_TILT_INVERT_STATE), - config.get(CONF_SET_POSITION_TOPIC), - set_position_template, - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash - )]) + async_add_entities([MqttCover(config, discovery_hash)]) class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, CoverDevice): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, name, state_topic, get_position_topic, - command_topic, availability_topic, - tilt_command_topic, tilt_status_topic, qos, retain, - state_open, state_closed, position_open, position_closed, - payload_open, payload_close, payload_stop, payload_available, - payload_not_available, optimistic, value_template, - tilt_open_position, tilt_closed_position, tilt_min, tilt_max, - tilt_optimistic, tilt_invert, set_position_topic, - set_position_template, unique_id: Optional[str], - device_config: Optional[ConfigType], discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the cover.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) + self._unique_id = config.get(CONF_UNIQUE_ID) self._position = None self._state = None - self._name = name - self._state_topic = state_topic - self._get_position_topic = get_position_topic - self._command_topic = command_topic - self._tilt_command_topic = tilt_command_topic - self._tilt_status_topic = tilt_status_topic - self._qos = qos - self._payload_open = payload_open - self._payload_close = payload_close - self._payload_stop = payload_stop - self._state_open = state_open - self._state_closed = state_closed - self._position_open = position_open - self._position_closed = position_closed - self._retain = retain - self._tilt_open_position = tilt_open_position - self._tilt_closed_position = tilt_closed_position - self._optimistic = (optimistic or (state_topic is None and - get_position_topic is None)) - self._template = value_template + self._sub_state = None + + self._optimistic = None self._tilt_value = None - self._tilt_min = tilt_min - self._tilt_max = tilt_max - self._tilt_optimistic = tilt_optimistic - self._tilt_invert = tilt_invert - self._set_position_topic = set_position_topic - self._set_position_template = set_position_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash + self._tilt_optimistic = 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) + qos = config.get(CONF_QOS) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, 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 super().async_added_to_hass() + 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): + self._config = config + self._optimistic = (config.get(CONF_OPTIMISTIC) or + (config.get(CONF_STATE_TOPIC) is None and + config.get(CONF_GET_POSITION_TOPIC) is None)) + self._tilt_optimistic = config.get(CONF_TILT_STATE_OPTIMISTIC) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass + set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) + if set_position_template is not None: + set_position_template.hass = self.hass + + topics = {} @callback def tilt_updated(topic, payload, qos): """Handle tilt updates.""" if (payload.isnumeric() and - self._tilt_min <= int(payload) <= self._tilt_max): + (self._config.get(CONF_TILT_MIN) <= int(payload) <= + self._config.get(CONF_TILT_MAX))): level = self.find_percentage_in_range(float(payload)) self._tilt_value = level @@ -260,13 +225,13 @@ def tilt_updated(topic, payload, qos): @callback def state_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) - if payload == self._state_open: + if payload == self._config.get(CONF_STATE_OPEN): self._state = False - elif payload == self._state_closed: + elif payload == self._config.get(CONF_STATE_CLOSED): self._state = True else: _LOGGER.warning("Payload is not True or False: %s", payload) @@ -276,8 +241,8 @@ def state_message_received(topic, payload, qos): @callback def position_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) if payload.isnumeric(): @@ -292,25 +257,38 @@ def position_message_received(topic, payload, qos): return self.async_schedule_update_ha_state() - if self._get_position_topic: - await mqtt.async_subscribe( - self.hass, self._get_position_topic, - position_message_received, self._qos) - elif self._state_topic: - await mqtt.async_subscribe( - self.hass, self._state_topic, - state_message_received, self._qos) + if self._config.get(CONF_GET_POSITION_TOPIC): + topics['get_position_topic'] = { + 'topic': self._config.get(CONF_GET_POSITION_TOPIC), + 'msg_callback': position_message_received, + 'qos': self._config.get(CONF_QOS)} + elif self._config.get(CONF_STATE_TOPIC): + topics['state_topic'] = { + 'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': state_message_received, + 'qos': self._config.get(CONF_QOS)} else: # Force into optimistic mode. self._optimistic = True - if self._tilt_status_topic is None: + if self._config.get(CONF_TILT_STATUS_TOPIC) is None: self._tilt_optimistic = True else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN - await mqtt.async_subscribe( - self.hass, self._tilt_status_topic, tilt_updated, self._qos) + topics['tilt_status_topic'] = { + 'topic': self._config.get(CONF_TILT_STATUS_TOPIC), + 'msg_callback': tilt_updated, + 'qos': self._config.get(CONF_QOS)} + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + 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): @@ -325,7 +303,7 @@ def assumed_state(self): @property def name(self): """Return the name of the cover.""" - return self._name + return self._config.get(CONF_NAME) @property def is_closed(self): @@ -349,13 +327,13 @@ def current_cover_tilt_position(self): def supported_features(self): """Flag supported features.""" supported_features = 0 - if self._command_topic is not None: + if self._config.get(CONF_COMMAND_TOPIC) is not None: supported_features = OPEN_CLOSE_FEATURES - if self._set_position_topic is not None: + if self._config.get(CONF_SET_POSITION_TOPIC) is not None: supported_features |= SUPPORT_SET_POSITION - if self._tilt_command_topic is not None: + if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: supported_features |= TILT_FEATURES return supported_features @@ -366,14 +344,15 @@ async def async_open_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_open, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_OPEN), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that cover has changed state. self._state = False - if self._get_position_topic: + if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( - self._position_open, COVER_PAYLOAD) + self._config.get(CONF_POSITION_OPEN), COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_close_cover(self, **kwargs): @@ -382,14 +361,15 @@ async def async_close_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_close, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_CLOSE), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that cover has changed state. self._state = True - if self._get_position_topic: + if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( - self._position_closed, COVER_PAYLOAD) + self._config.get(CONF_POSITION_CLOSED), COVER_PAYLOAD) self.async_schedule_update_ha_state() async def async_stop_cover(self, **kwargs): @@ -398,25 +378,30 @@ async def async_stop_cover(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_stop, self._qos, - self._retain) + self.hass, self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_STOP), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_open_position, self._qos, - self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config.get(CONF_TILT_OPEN_POSITION), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._tilt_optimistic: - self._tilt_value = self._tilt_open_position + self._tilt_value = self._config.get(CONF_TILT_OPEN_POSITION) self.async_schedule_update_ha_state() async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" - mqtt.async_publish(self.hass, self._tilt_command_topic, - self._tilt_closed_position, self._qos, - self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + self._config.get(CONF_TILT_CLOSED_POSITION), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._tilt_optimistic: - self._tilt_value = self._tilt_closed_position + self._tilt_value = self._config.get(CONF_TILT_CLOSED_POSITION) self.async_schedule_update_ha_state() async def async_set_cover_tilt_position(self, **kwargs): @@ -429,29 +414,38 @@ async def async_set_cover_tilt_position(self, **kwargs): # The position needs to be between min and max level = self.find_in_range_from_percent(position) - mqtt.async_publish(self.hass, self._tilt_command_topic, - level, self._qos, self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_TILT_COMMAND_TOPIC), + level, + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" + set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE) if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] percentage_position = position - if self._set_position_template is not None: + if set_position_template is not None: try: - position = self._set_position_template.async_render( + position = set_position_template.async_render( **kwargs) except TemplateError as ex: _LOGGER.error(ex) self._state = None - elif self._position_open != 100 and self._position_closed != 0: + elif (self._config.get(CONF_POSITION_OPEN) != 100 and + self._config.get(CONF_POSITION_CLOSED) != 0): position = self.find_in_range_from_percent( position, COVER_PAYLOAD) - mqtt.async_publish(self.hass, self._set_position_topic, - position, self._qos, self._retain) + mqtt.async_publish(self.hass, + self._config.get(CONF_SET_POSITION_TOPIC), + position, + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: - self._state = percentage_position == self._position_closed + self._state = percentage_position == \ + self._config.get(CONF_POSITION_CLOSED) self._position = percentage_position self.async_schedule_update_ha_state() @@ -459,11 +453,11 @@ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" # the range of motion as defined by the min max values if range_type == COVER_PAYLOAD: - max_range = self._position_open - min_range = self._position_closed + max_range = self._config.get(CONF_POSITION_OPEN) + min_range = self._config.get(CONF_POSITION_CLOSED) else: - max_range = self._tilt_max - min_range = self._tilt_min + max_range = self._config.get(CONF_TILT_MAX) + min_range = self._config.get(CONF_TILT_MIN) current_range = max_range - min_range # offset to be zero based offset_position = position - min_range @@ -474,7 +468,8 @@ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): min_percent = 0 position_percentage = min(max(position_percentage, min_percent), max_percent) - if range_type == TILT_PAYLOAD and self._tilt_invert: + if range_type == TILT_PAYLOAD and \ + self._config.get(CONF_TILT_INVERT_STATE): return 100 - position_percentage return position_percentage @@ -488,17 +483,18 @@ def find_in_range_from_percent(self, percentage, range_type=TILT_PAYLOAD): returning the offset """ if range_type == COVER_PAYLOAD: - max_range = self._position_open - min_range = self._position_closed + max_range = self._config.get(CONF_POSITION_OPEN) + min_range = self._config.get(CONF_POSITION_CLOSED) else: - max_range = self._tilt_max - min_range = self._tilt_min + max_range = self._config.get(CONF_TILT_MAX) + min_range = self._config.get(CONF_TILT_MIN) offset = min_range current_range = max_range - min_range position = round(current_range * (percentage / 100.0)) position += offset - if range_type == TILT_PAYLOAD and self._tilt_invert: + if range_type == TILT_PAYLOAD and \ + self._config.get(CONF_TILT_INVERT_STATE): position = max_range - position + offset return position diff --git a/homeassistant/components/cover/tellduslive.py b/homeassistant/components/cover/tellduslive.py index 9d292d9e8b5bb3..67affdae04e4b9 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -8,8 +8,9 @@ """ import logging +from homeassistant.components import tellduslive from homeassistant.components.cover import CoverDevice -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if discovery_info is None: return - add_entities(TelldusLiveCover(hass, cover) for cover in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities(TelldusLiveCover(client, cover) for cover in discovery_info) class TelldusLiveCover(TelldusLiveEntity, CoverDevice): @@ -33,14 +35,11 @@ def is_closed(self): def close_cover(self, **kwargs): """Close the cover.""" self.device.down() - self.changed() def open_cover(self, **kwargs): """Open the cover.""" self.device.up() - self.changed() def stop_cover(self, **kwargs): """Stop the cover.""" self.device.stop() - self.changed() diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 4fcd33bee26da4..e2e4572939d24c 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -132,3 +132,8 @@ def update(self, **kwargs): _LOGGER.warning( "Connection failed for %s", self.ip_address ) + + @property + def mac(self): + """Return mac-address of device.""" + return self.device.values.get('mac') diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 10eb9f5bc7316b..a3aa5491e23e93 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "Amfitri\u00f3", - "port": "Port (predeterminat: '80')" + "port": "Port" }, "title": "Definiu la passarel\u00b7la deCONZ" }, diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 3de7de9ddb3e6e..51cb5419e90dcf 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -17,7 +17,7 @@ "title": "deCONZ gateway d\u00e9fin\u00e9ieren" }, "link": { - "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op\u00a0deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", + "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", "title": "Link mat deCONZ" }, "options": { diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index a9b66314f3152d..3ff60254a6a50c 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -12,7 +12,7 @@ "init": { "data": { "host": "\u0425\u043e\u0441\u0442", - "port": "\u041f\u043e\u0440\u0442 (\u043f\u043e \u0443\u043c\u043e\u043b\u0447\u0430\u043d\u0438\u044e: '80')" + "port": "\u041f\u043e\u0440\u0442" }, "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" }, diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index ad792d035ccd32..202883713c740f 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -22,9 +22,8 @@ from homeassistant.config import load_yaml_config_file, async_log_exception from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType import homeassistant.helpers.config_validation as cv from homeassistant import util @@ -384,7 +383,6 @@ def async_update_stale(self, now: dt_util.dt.datetime): 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): @@ -407,7 +405,7 @@ async def async_init_single_device(dev): await asyncio.wait(tasks, loop=self.hass.loop) -class Device(Entity): +class Device(RestoreEntity): """Represent a tracked device.""" host_name = None # type: str @@ -575,7 +573,8 @@ async def async_update(self): async def async_added_to_hass(self): """Add an entity.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/device_tracker/bt_smarthub.py b/homeassistant/components/device_tracker/bt_smarthub.py index e7d60aaed6dd36..821182ec1036f6 100644 --- a/homeassistant/components/device_tracker/bt_smarthub.py +++ b/homeassistant/components/device_tracker/bt_smarthub.py @@ -13,7 +13,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['btsmarthub_devicelist==0.1.1'] +REQUIREMENTS = ['btsmarthub_devicelist==0.1.3'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 1995179ff5abec..1f95414541cc2d 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.8'] +REQUIREMENTS = ['locationsharinglib==3.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index 5b69c13afa64cf..cddcd1f26eefbc 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_METHOD) -REQUIREMENTS = ['librouteros==2.1.1'] +REQUIREMENTS = ['librouteros==2.2.0'] _LOGGER = logging.getLogger(__name__) @@ -128,7 +128,8 @@ def connect_to_device(self): librouteros.exceptions.ConnectionError): self.wireless_exist = False - if not self.wireless_exist or self.method == 'ip': + if not self.wireless_exist and not self.capsman_exist \ + or self.method == 'ip': _LOGGER.info( "Mikrotik %s: Wireless adapters not found. Try to " "use DHCP lease table as presence tracker source. " @@ -143,12 +144,18 @@ def connect_to_device(self): librouteros.exceptions.MultiTrapError, librouteros.exceptions.ConnectionError) as api_error: _LOGGER.error("Connection error: %s", api_error) - return self.connected def scan_devices(self): """Scan for new devices and return a list with found device MACs.""" - self._update_info() + import librouteros + try: + self._update_info() + except (librouteros.exceptions.TrapError, + librouteros.exceptions.MultiTrapError, + librouteros.exceptions.ConnectionError) as api_error: + _LOGGER.error("Connection error: %s", api_error) + self.connect_to_device() return [device for device in self.last_results] def get_device_name(self, device): diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index a9afc76e67c273..40a6f48d889031 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py index 7872f8f1f1c89f..395b539a065cb5 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -7,33 +7,32 @@ import logging from homeassistant.util import slugify -from homeassistant.helpers.dispatcher import ( - dispatcher_connect, dispatcher_send) -from homeassistant.components.volvooncall import DATA_KEY, SIGNAL_VEHICLE_SEEN +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.volvooncall import DATA_KEY, SIGNAL_STATE_UPDATED _LOGGER = logging.getLogger(__name__) -def setup_scanner(hass, config, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Set up the Volvo tracker.""" if discovery_info is None: return - vin, _ = discovery_info - voc = hass.data[DATA_KEY] - vehicle = voc.vehicles[vin] + vin, component, attr = discovery_info + data = hass.data[DATA_KEY] + instrument = data.instrument(vin, component, attr) - def see_vehicle(vehicle): + async def see_vehicle(): """Handle the reporting of the vehicle position.""" - host_name = voc.vehicle_name(vehicle) + host_name = instrument.vehicle_name dev_id = 'volvo_{}'.format(slugify(host_name)) - see(dev_id=dev_id, - host_name=host_name, - gps=(vehicle.position['latitude'], - vehicle.position['longitude']), - icon='mdi:car') - - dispatcher_connect(hass, SIGNAL_VEHICLE_SEEN, see_vehicle) - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + await async_see(dev_id=dev_id, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=instrument.state, + icon='mdi:car') + + async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle) return True diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index 1abd86ffd8a477..c5c6ebcbc35ecc 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_HOST, CONF_TOKEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json index aa81c06d750e99..ffc10269776bfa 100644 --- a/homeassistant/components/dialogflow/.translations/ca.json +++ b/homeassistant/components/dialogflow/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/json\n\nConsulteu [la documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/json\n\nVegeu [la documentaci\u00f3]({docs_url}) per a m\u00e9s detalls." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/.translations/hu.json b/homeassistant/components/dialogflow/.translations/hu.json new file mode 100644 index 00000000000000..89e8205bb09ef9 --- /dev/null +++ b/homeassistant/components/dialogflow/.translations/hu.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Dialogflow" + } +} \ No newline at end of file diff --git a/homeassistant/components/dialogflow/.translations/ko.json b/homeassistant/components/dialogflow/.translations/ko.json index f9a71747bd649e..cf53f81bdb8e94 100644 --- a/homeassistant/components/dialogflow/.translations/ko.json +++ b/homeassistant/components/dialogflow/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow Webhook]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow Webhook]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/.translations/ru.json b/homeassistant/components/dialogflow/.translations/ru.json index 7bc785f2613fa6..8625780e65c05d 100644 --- a/homeassistant/components/dialogflow/.translations/ru.json +++ b/homeassistant/components/dialogflow/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "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 Dialogflow?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Dialogflow Webhook" + "title": "Dialogflow Webhook" } }, "title": "Dialogflow" diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 96c79053dffbc4..bbf40c73070486 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -134,6 +134,7 @@ async def new_service_found(service, info): discovery_hash = json.dumps([service, info], sort_keys=True) if discovery_hash in already_discovered: + logger.debug("Already discoverd service %s %s.", service, info) return already_discovered.add(discovery_hash) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 9424800060130b..8ac3cec641131f 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ DOMAIN = "elkm1" -REQUIREMENTS = ['elkm1-lib==0.7.12'] +REQUIREMENTS = ['elkm1-lib==0.7.13'] CONF_AREA = 'area' CONF_COUNTER = 'counter' diff --git a/homeassistant/components/evohome.py b/homeassistant/components/evohome.py index 397d3b9f6c09cd..40ba5b9b70ff09 100644 --- a/homeassistant/components/evohome.py +++ b/homeassistant/components/evohome.py @@ -1,4 +1,4 @@ -"""Support for Honeywell evohome (EMEA/EU-based systems only). +"""Support for (EMEA/EU-based) Honeywell evohome systems. Support for a temperature control system (TCS, controller) with 0+ heating zones (e.g. TRVs, relays) and, optionally, a DHW controller. @@ -8,46 +8,48 @@ """ # Glossary: -# TCS - temperature control system (a.k.a. Controller, Parent), which can -# have up to 13 Children: -# 0-12 Heating zones (a.k.a. Zone), and -# 0-1 DHW controller, (a.k.a. Boiler) +# TCS - temperature control system (a.k.a. Controller, Parent), which can +# have up to 13 Children: +# 0-12 Heating zones (a.k.a. Zone), and +# 0-1 DHW controller, (a.k.a. Boiler) +# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater +from datetime import timedelta import logging from requests.exceptions import HTTPError import voluptuous as vol from homeassistant.const import ( - CONF_USERNAME, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - HTTP_BAD_REQUEST + CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD, + EVENT_HOMEASSISTANT_START, + HTTP_BAD_REQUEST, HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS ) - +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['evohomeclient==0.2.7'] -# If ever > 0.2.7, re-check the work-around wrapper is still required when -# instantiating the client, below. +REQUIREMENTS = ['evohomeclient==0.2.8'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'evohome' DATA_EVOHOME = 'data_' + DOMAIN +DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN CONF_LOCATION_IDX = 'location_idx' -MAX_TEMP = 28 -MIN_TEMP = 5 -SCAN_INTERVAL_DEFAULT = 180 -SCAN_INTERVAL_MAX = 300 +SCAN_INTERVAL_DEFAULT = timedelta(seconds=300) +SCAN_INTERVAL_MINIMUM = timedelta(seconds=180) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int, + vol.Optional(CONF_LOCATION_IDX, default=0): + cv.positive_int, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT): + vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)), }), }, extra=vol.ALLOW_EXTRA) @@ -55,91 +57,107 @@ GWS = 'gateways' TCS = 'temperatureControlSystems' +# bit masks for dispatcher packets +EVO_PARENT = 0x01 +EVO_CHILD = 0x02 + -def setup(hass, config): - """Create a Honeywell (EMEA/EU) evohome CH/DHW system. +def setup(hass, hass_config): + """Create a (EMEA/EU-based) Honeywell evohome system. - One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a - DHW controller. Does not work for US-based systems. + Currently, only the Controller and the Zones are implemented here. """ evo_data = hass.data[DATA_EVOHOME] = {} evo_data['timers'] = {} - evo_data['params'] = dict(config[DOMAIN]) - evo_data['params'][CONF_SCAN_INTERVAL] = SCAN_INTERVAL_DEFAULT + # use a copy, since scan_interval is rounded up to nearest 60s + evo_data['params'] = dict(hass_config[DOMAIN]) + scan_interval = evo_data['params'][CONF_SCAN_INTERVAL] + scan_interval = timedelta( + minutes=(scan_interval.total_seconds() + 59) // 60) from evohomeclient2 import EvohomeClient - _LOGGER.debug("setup(): API call [4 request(s)]: client.__init__()...") - try: - # There's a bug in evohomeclient2 v0.2.7: the client.__init__() sets - # the root loglevel when EvohomeClient(debug=?), so remember it now... - log_level = logging.getLogger().getEffectiveLevel() - client = EvohomeClient( evo_data['params'][CONF_USERNAME], evo_data['params'][CONF_PASSWORD], debug=False ) - # ...then restore it to what it was before instantiating the client - logging.getLogger().setLevel(log_level) except HTTPError as err: if err.response.status_code == HTTP_BAD_REQUEST: _LOGGER.error( - "Failed to establish a connection with evohome web servers, " + "setup(): Failed to connect with the vendor's web servers. " "Check your username (%s), and password are correct." "Unable to continue. Resolve any errors and restart HA.", evo_data['params'][CONF_USERNAME] ) - return False # unable to continue - raise # we dont handle any other HTTPErrors + elif err.response.status_code == HTTP_SERVICE_UNAVAILABLE: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "The server is not contactable. Unable to continue. " + "Resolve any errors and restart HA." + ) + + elif err.response.status_code == HTTP_TOO_MANY_REQUESTS: + _LOGGER.error( + "setup(): Failed to connect with the vendor's web servers. " + "You have exceeded the api rate limit. Unable to continue. " + "Wait a while (say 10 minutes) and restart HA." + ) + + else: + raise # we dont expect/handle any other HTTPErrors - finally: # Redact username, password as no longer needed. + return False # unable to continue + + finally: # Redact username, password as no longer needed evo_data['params'][CONF_USERNAME] = 'REDACTED' evo_data['params'][CONF_PASSWORD] = 'REDACTED' evo_data['client'] = client + evo_data['status'] = {} - # Redact any installation data we'll never need. - if client.installation_info[0]['locationInfo']['locationId'] != 'REDACTED': - for loc in client.installation_info: - loc['locationInfo']['streetAddress'] = 'REDACTED' - loc['locationInfo']['city'] = 'REDACTED' - loc['locationInfo']['locationOwner'] = 'REDACTED' - loc[GWS][0]['gatewayInfo'] = 'REDACTED' + # Redact any installation data we'll never need + for loc in client.installation_info: + loc['locationInfo']['locationId'] = 'REDACTED' + loc['locationInfo']['locationOwner'] = 'REDACTED' + loc['locationInfo']['streetAddress'] = 'REDACTED' + loc['locationInfo']['city'] = 'REDACTED' + loc[GWS][0]['gatewayInfo'] = 'REDACTED' - # Pull down the installation configuration. + # Pull down the installation configuration loc_idx = evo_data['params'][CONF_LOCATION_IDX] try: evo_data['config'] = client.installation_info[loc_idx] - except IndexError: _LOGGER.warning( - "setup(): Parameter '%s' = %s , is outside its range (0-%s)", + "setup(): Parameter '%s'=%s, is outside its range (0-%s)", CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1 ) - return False # unable to continue - evo_data['status'] = {} - if _LOGGER.isEnabledFor(logging.DEBUG): tmp_loc = dict(evo_data['config']) tmp_loc['locationInfo']['postcode'] = 'REDACTED' - tmp_tcs = tmp_loc[GWS][0][TCS][0] - if 'zones' in tmp_tcs: - tmp_tcs['zones'] = '...' - if 'dhw' in tmp_tcs: - tmp_tcs['dhw'] = '...' + if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW... + tmp_loc[GWS][0][TCS][0]['dhw'] = '...' + + _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc) + + load_platform(hass, 'climate', DOMAIN, {}, hass_config) - _LOGGER.debug("setup(), location = %s", tmp_loc) + @callback + def _first_update(event): + # When HA has started, the hub knows to retreive it's first update + pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT} + async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt) - load_platform(hass, 'climate', DOMAIN, {}, config) + hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update) return True diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 1ff04cd913a187..8f3ec84282995f 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/fan.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -18,7 +17,7 @@ ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, MqttDiscoveryUpdate, - MqttEntityDeviceInfo) + MqttEntityDeviceInfo, subscription) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType @@ -107,8 +106,63 @@ async def _async_setup_entity(hass, config, async_add_entities, discovery_hash=None): """Set up the MQTT fan.""" async_add_entities([MqttFan( - config.get(CONF_NAME), - { + config, + discovery_hash, + )]) + + +class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, + FanEntity): + """A MQTT fan component.""" + + def __init__(self, config, discovery_hash): + """Initialize the MQTT fan.""" + self._unique_id = config.get(CONF_UNIQUE_ID) + self._state = False + self._speed = None + self._oscillation = None + self._supported_features = 0 + self._sub_state = None + + self._topic = None + self._payload = None + self._templates = None + self._optimistic = None + self._optimistic_oscillation = None + self._optimistic_speed = 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) + qos = config.get(CONF_QOS) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, 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 to MQTT events.""" + await super().async_added_to_hass() + 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._config = config + self._topic = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, @@ -117,15 +171,13 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_OSCILLATION_STATE_TOPIC, CONF_OSCILLATION_COMMAND_TOPIC, ) - }, - { + } + self._templates = { CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE), OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE) - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - { + } + self._payload = { STATE_ON: config.get(CONF_PAYLOAD_ON), STATE_OFF: config.get(CONF_PAYLOAD_OFF), OSCILLATE_ON_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_ON), @@ -133,59 +185,25 @@ async def _async_setup_entity(hass, config, async_add_entities, SPEED_LOW: config.get(CONF_PAYLOAD_LOW_SPEED), SPEED_MEDIUM: config.get(CONF_PAYLOAD_MEDIUM_SPEED), SPEED_HIGH: config.get(CONF_PAYLOAD_HIGH_SPEED), - }, - config.get(CONF_SPEED_LIST), - config.get(CONF_OPTIMISTIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_UNIQUE_ID), - config.get(CONF_DEVICE), - discovery_hash, - )]) - - -class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - FanEntity): - """A MQTT fan component.""" - - def __init__(self, name, topic, templates, qos, retain, payload, - speed_list, optimistic, availability_topic, payload_available, - payload_not_available, unique_id: Optional[str], - device_config: Optional[ConfigType], discovery_hash): - """Initialize the MQTT fan.""" - 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._topic = topic - self._qos = qos - self._retain = retain - self._payload = payload - self._templates = templates - self._speed_list = speed_list - self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None self._optimistic_oscillation = ( - optimistic or topic[CONF_OSCILLATION_STATE_TOPIC] is None) + optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None) self._optimistic_speed = ( - optimistic or topic[CONF_SPEED_STATE_TOPIC] is None) - self._state = False - self._speed = None - self._oscillation = None + optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None) + self._supported_features = 0 - self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] + self._supported_features |= (self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None and SUPPORT_OSCILLATE) - self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] + self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC] is not None and SUPPORT_SET_SPEED) - self._unique_id = unique_id - self._discovery_hash = discovery_hash - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + self._unique_id = config.get(CONF_UNIQUE_ID) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -205,9 +223,10 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + topics[CONF_STATE_TOPIC] = { + 'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._config.get(CONF_QOS)} @callback def speed_received(topic, payload, qos): @@ -222,9 +241,10 @@ def speed_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_SPEED_STATE_TOPIC], speed_received, - self._qos) + topics[CONF_SPEED_STATE_TOPIC] = { + 'topic': self._topic[CONF_SPEED_STATE_TOPIC], + 'msg_callback': speed_received, + 'qos': self._config.get(CONF_QOS)} self._speed = SPEED_OFF @callback @@ -238,11 +258,21 @@ def oscillation_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_OSCILLATION_STATE_TOPIC], - oscillation_received, self._qos) + topics[CONF_OSCILLATION_STATE_TOPIC] = { + 'topic': self._topic[CONF_OSCILLATION_STATE_TOPIC], + 'msg_callback': oscillation_received, + 'qos': self._config.get(CONF_QOS)} self._oscillation = False + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + 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): """No polling needed for a MQTT fan.""" @@ -261,12 +291,12 @@ def is_on(self): @property def name(self) -> str: """Get entity name.""" - return self._name + return self._config.get(CONF_NAME) @property def speed_list(self) -> list: """Get the list of available speeds.""" - return self._speed_list + return self._config.get(CONF_SPEED_LIST) @property def supported_features(self) -> int: @@ -290,7 +320,8 @@ async def async_turn_on(self, speed: str = None, **kwargs) -> None: """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_ON], self._qos, self._retain) + self._payload[STATE_ON], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if speed: await self.async_set_speed(speed) @@ -301,7 +332,8 @@ async def async_turn_off(self, **kwargs) -> None: """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload[STATE_OFF], self._qos, self._retain) + self._payload[STATE_OFF], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan. @@ -322,7 +354,8 @@ async def async_set_speed(self, speed: str) -> None: mqtt.async_publish( self.hass, self._topic[CONF_SPEED_COMMAND_TOPIC], - mqtt_payload, self._qos, self._retain) + mqtt_payload, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_speed: self._speed = speed @@ -343,7 +376,7 @@ async def async_oscillate(self, oscillating: bool) -> None: mqtt.async_publish( self.hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC], - payload, self._qos, self._retain) + payload, self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic_oscillation: self._oscillation = oscillating diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 3462b0bc1eb726..e6349782cd150a 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -18,7 +18,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -755,12 +755,13 @@ def __init__(self, name, device, model, unique_id): if self._model == MODEL_AIRHUMIDIFIER_CA: self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA - self._speed_list = [mode.name for mode in OperationMode] + self._speed_list = [mode.name for mode in OperationMode if + mode is not OperationMode.Strong] else: self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER self._speed_list = [mode.name for mode in OperationMode if - mode.name != 'Auto'] + mode is not OperationMode.Auto] self._state_attrs.update( {attribute: None for attribute in self._available_attributes}) diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index b5615f18d730ed..d1731e89894d0c 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,10 +5,15 @@ at https://home-assistant.io/components/fan.zha/ """ import logging -from homeassistant.components import zha + from homeassistant.components.fan import ( - DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, - SUPPORT_SET_SPEED) + DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, + FanEntity) +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['zha'] @@ -39,15 +44,38 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation fans.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation fans.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation fan from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if fans is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + fans.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA fans.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(ZhaFan(**discovery_info)) - async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) + async_add_entities(entities, update_before_add=True) -class ZhaFan(zha.Entity, FanEntity): +class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN @@ -101,9 +129,9 @@ async def async_set_speed(self, speed: str) -> None: async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.fan, ['fan_mode'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.fan, ['fan_mode'], + allow_cache=False, + only_cache=(not self._initialized)) new_value = result.get('fan_mode', None) self._state = VALUE_TO_SPEED.get(new_value, None) diff --git a/homeassistant/components/fibaro.py b/homeassistant/components/fibaro.py index 85bd5c3c0181e9..5813b1948909ff 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -7,6 +7,7 @@ import logging from collections import defaultdict +from typing import Optional import voluptuous as vol from homeassistant.const import (ATTR_ARMED, ATTR_BATTERY_LEVEL, @@ -27,7 +28,8 @@ ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh" CONF_PLUGINS = "plugins" -FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', 'sensor', 'switch'] +FIBARO_COMPONENTS = ['binary_sensor', 'cover', 'light', + 'scene', 'sensor', 'switch'] FIBARO_TYPEMAP = { 'com.fibaro.multilevelSensor': "sensor", @@ -43,7 +45,8 @@ 'com.fibaro.smokeSensor': 'binary_sensor', 'com.fibaro.remoteSwitch': 'switch', 'com.fibaro.sensor': 'sensor', - 'com.fibaro.colorController': 'light' + 'com.fibaro.colorController': 'light', + 'com.fibaro.securitySensor': 'binary_sensor' } CONFIG_SCHEMA = vol.Schema({ @@ -63,19 +66,23 @@ class FibaroController(): _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 + _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) + self._scene_map = None + self.hub_serial = None # Unique serial number of the hub def connect(self): """Start the communication with the Fibaro controller.""" try: login = self._client.login.get() + info = self._client.info.get() + self.hub_serial = slugify(info.serialNumber) except AssertionError: _LOGGER.error("Can't connect to Fibaro HC. " "Please check URL.") @@ -87,6 +94,7 @@ def connect(self): self._room_map = {room.id: room for room in self._client.rooms.list()} self._read_devices() + self._read_scenes() return True def enable_state_handler(self): @@ -166,6 +174,25 @@ def _map_device_to_type(device): device_type = 'light' return device_type + def _read_scenes(self): + scenes = self._client.scenes.list() + self._scene_map = {} + for device in scenes: + if not device.visible: + continue + if device.roomID == 0: + room_name = 'Unknown' + else: + room_name = self._room_map[device.roomID].name + device.room_name = room_name + device.friendly_name = '{} {}'.format(room_name, device.name) + device.ha_id = '{}_{}_{}'.format( + slugify(room_name), slugify(device.name), device.id) + device.unique_id_str = "{}.{}".format( + self.hub_serial, device.id) + self._scene_map[device.id] = device + self.fibaro_devices['scene'].append(device) + def _read_devices(self): """Read and process the device list.""" devices = self._client.devices.list() @@ -177,6 +204,7 @@ def _read_devices(self): room_name = 'Unknown' else: room_name = self._room_map[device.roomID].name + device.room_name = room_name device.friendly_name = room_name + ' ' + device.name device.ha_id = '{}_{}_{}'.format( slugify(room_name), slugify(device.name), device.id) @@ -187,6 +215,8 @@ def _read_devices(self): else: device.mapped_type = None if device.mapped_type: + device.unique_id_str = "{}.{}".format( + self.hub_serial, device.id) self._device_map[device.id] = device self.fibaro_devices[device.mapped_type].append(device) else: @@ -283,11 +313,14 @@ def call_turn_off(self): 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)) + red = int(max(0, min(255, red))) + green = int(max(0, min(255, green))) + blue = int(max(0, min(255, blue))) + white = int(max(0, min(255, white))) + color_str = "{},{},{},{}".format(red, green, blue, white) self.fibaro_device.properties.color = color_str - self.action("setColor", str(int(red)), str(int(green)), - str(int(blue)), str(int(white))) + self.action("setColor", str(red), str(green), + str(blue), str(white)) def action(self, cmd, *args): """Perform an action on the Fibaro HC.""" @@ -324,7 +357,12 @@ def current_binary_state(self): return False @property - def name(self): + def unique_id(self) -> str: + """Return a unique ID.""" + return self.fibaro_device.unique_id_str + + @property + def name(self) -> Optional[str]: """Return the name of the device.""" return self._name @@ -357,5 +395,5 @@ def device_state_attributes(self): except (ValueError, KeyError): pass - attr['id'] = self.ha_id + attr['fibaro_id'] = self.fibaro_device.id return attr diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d8ea057a4f08b8..8caca59130519b 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==20181121.1'] +REQUIREMENTS = ['home-assistant-frontend==20181211.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', @@ -238,7 +238,7 @@ async def async_setup(hass, config): if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version, hass.auth.active) + index_view = IndexView(repo_path, js_version) hass.http.register_view(index_view) hass.http.register_view(AuthorizeView(repo_path, js_version)) @@ -250,7 +250,7 @@ def async_finalize_panel(panel): await asyncio.wait( [async_register_built_in_panel(hass, panel) for panel in ( 'dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', 'profile')], + 'dev-template', 'dev-mqtt', 'kiosk', 'states', 'profile')], loop=hass.loop) hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel @@ -362,13 +362,11 @@ class IndexView(HomeAssistantView): url = '/' name = 'frontend:index' requires_auth = False - extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option, auth_active): + def __init__(self, repo_path, js_option): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option - self.auth_active = auth_active self._template_cache = {} def get_template(self, latest): @@ -415,8 +413,6 @@ async def get(self, request, extra=None): # do not try to auto connect on load no_auth = '0' - use_oauth = '1' if self.auth_active else '0' - template = await hass.async_add_job(self.get_template, latest) extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 @@ -425,7 +421,7 @@ async def get(self, request, extra=None): no_auth=no_auth, theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], - use_oauth=use_oauth + use_oauth='1' ) return web.Response(text=template.render(**template_params), diff --git a/homeassistant/components/geo_location/geo_json_events.py b/homeassistant/components/geo_location/geo_json_events.py index 74d1b036f6c1e6..4d8c3b68edd7c2 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -13,7 +13,8 @@ from homeassistant.components.geo_location import ( PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( - CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START) + CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL, EVENT_HOMEASSISTANT_START, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -38,6 +39,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) @@ -46,10 +49,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the GeoJSON Events platform.""" url = config[CONF_URL] scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] # Initialize the entity manager. - feed = GeoJsonFeedManager(hass, add_entities, scan_interval, url, - radius_in_km) + feed = GeoJsonFeedEntityManager( + hass, add_entities, scan_interval, coordinates, url, radius_in_km) def start_feed_manager(event): """Start feed manager.""" @@ -58,87 +63,49 @@ def start_feed_manager(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class GeoJsonFeedManager: - """Feed Manager for GeoJSON feeds.""" +class GeoJsonFeedEntityManager: + """Feed Entity Manager for GeoJSON feeds.""" - def __init__(self, hass, add_entities, scan_interval, url, radius_in_km): + def __init__(self, hass, add_entities, scan_interval, coordinates, url, + radius_in_km): """Initialize the GeoJSON Feed Manager.""" - from geojson_client.generic_feed import GenericFeed + from geojson_client.generic_feed import GenericFeedManager self._hass = hass - self._feed = GenericFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, url=url) + self._feed_manager = GenericFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, url, filter_radius=radius_in_km) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) - - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client - - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = GeoJsonLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = GeoJsonLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class GeoJsonLocationEvent(GeoLocationEvent): @@ -184,7 +151,7 @@ def should_poll(self): async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py index 1d2a7fadaff494..5681e4a53ace33 100644 --- a/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py +++ b/homeassistant/components/geo_location/nsw_rural_fire_service_feed.py @@ -14,7 +14,7 @@ PLATFORM_SCHEMA, GeoLocationEvent) from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_RADIUS, CONF_SCAN_INTERVAL, - EVENT_HOMEASSISTANT_START) + EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -57,18 +57,23 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_CATEGORIES, default=[]): vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]), + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), }) def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the GeoJSON Events platform.""" + """Set up the NSW Rural Fire Service Feed platform.""" scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) radius_in_km = config[CONF_RADIUS] categories = config.get(CONF_CATEGORIES) # Initialize the entity manager. - feed = NswRuralFireServiceFeedManager( - hass, add_entities, scan_interval, radius_in_km, categories) + feed = NswRuralFireServiceFeedEntityManager( + hass, add_entities, scan_interval, coordinates, radius_in_km, + categories) def start_feed_manager(event): """Start feed manager.""" @@ -77,93 +82,55 @@ def start_feed_manager(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) -class NswRuralFireServiceFeedManager: - """Feed Manager for NSW Rural Fire Service GeoJSON feed.""" +class NswRuralFireServiceFeedEntityManager: + """Feed Entity Manager for NSW Rural Fire Service GeoJSON feed.""" - def __init__(self, hass, add_entities, scan_interval, radius_in_km, - categories): - """Initialize the GeoJSON Feed Manager.""" + def __init__(self, hass, add_entities, scan_interval, coordinates, + radius_in_km, categories): + """Initialize the Feed Entity Manager.""" from geojson_client.nsw_rural_fire_service_feed \ - import NswRuralFireServiceFeed + import NswRuralFireServiceFeedManager self._hass = hass - self._feed = NswRuralFireServiceFeed( - (hass.config.latitude, hass.config.longitude), - filter_radius=radius_in_km, filter_categories=categories) + self._feed_manager = NswRuralFireServiceFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, filter_radius=radius_in_km, + filter_categories=categories) self._add_entities = add_entities self._scan_interval = scan_interval - self.feed_entries = {} - self._managed_external_ids = set() def startup(self): """Start up this manager.""" - self._update() + self._feed_manager.update() self._init_regular_updates() def _init_regular_updates(self): """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._update(), self._scan_interval) - - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client - - status, feed_entries = self._feed.update() - if status == geojson_client.UPDATE_OK: - _LOGGER.debug("Data retrieved %s", feed_entries) - # Keep a copy of all feed entries for future lookups by entities. - self.feed_entries = {entry.external_id: entry - for entry in feed_entries} - # For entity management the external ids from the feed are used. - feed_external_ids = set(self.feed_entries) - remove_external_ids = self._managed_external_ids.difference( - feed_external_ids) - self._remove_entities(remove_external_ids) - update_external_ids = self._managed_external_ids.intersection( - feed_external_ids) - self._update_entities(update_external_ids) - create_external_ids = feed_external_ids.difference( - self._managed_external_ids) - self._generate_new_entities(create_external_ids) - elif status == geojson_client.UPDATE_OK_NO_DATA: - _LOGGER.debug( - "Update successful, but no data received from %s", self._feed) - else: - _LOGGER.warning( - "Update not successful, no data received from %s", self._feed) - # Remove all entities. - self._remove_entities(self._managed_external_ids.copy()) - - def _generate_new_entities(self, external_ids): - """Generate new entities for events.""" - new_entities = [] - for external_id in external_ids: - new_entity = NswRuralFireServiceLocationEvent(self, external_id) - _LOGGER.debug("New entity added %s", external_id) - new_entities.append(new_entity) - self._managed_external_ids.add(external_id) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = NswRuralFireServiceLocationEvent(self, external_id) # Add new entities to HA. - self._add_entities(new_entities, True) + self._add_entities([new_entity], True) - def _update_entities(self, external_ids): - """Update entities.""" - for external_id in external_ids: - _LOGGER.debug("Existing entity found %s", external_id) - dispatcher_send( - self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entities(self, external_ids): - """Remove entities.""" - for external_id in external_ids: - _LOGGER.debug("Entity not current anymore %s", external_id) - self._managed_external_ids.remove(external_id) - dispatcher_send( - self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class NswRuralFireServiceLocationEvent(GeoLocationEvent): - """This represents an external event with GeoJSON data.""" + """This represents an external event with NSW Rural Fire Service data.""" def __init__(self, feed_manager, external_id): """Initialize entity with data from feed entry.""" @@ -209,13 +176,13 @@ def _update_callback(self): @property def should_poll(self): - """No polling needed for GeoJSON location events.""" + """No polling needed for NSW Rural Fire Service location events.""" return False async def async_update(self): """Update this entity from the data held in the feed manager.""" _LOGGER.debug("Updating %s", self._external_id) - feed_entry = self._feed_manager.feed_entries.get(self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) if feed_entry: self._update_from_feed(feed_entry) diff --git a/homeassistant/components/geo_location/usgs_earthquakes_feed.py b/homeassistant/components/geo_location/usgs_earthquakes_feed.py new file mode 100644 index 00000000000000..f835fecfeb4d85 --- /dev/null +++ b/homeassistant/components/geo_location/usgs_earthquakes_feed.py @@ -0,0 +1,268 @@ +""" +U.S. Geological Survey Earthquake Hazards Program Feed platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/geo_location/usgs_earthquakes_feed/ +""" +from datetime import timedelta +import logging +from typing import Optional + +import voluptuous as vol + +from homeassistant.components.geo_location import ( + PLATFORM_SCHEMA, GeoLocationEvent) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_RADIUS, CONF_SCAN_INTERVAL, + EVENT_HOMEASSISTANT_START, CONF_LATITUDE, CONF_LONGITUDE) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['geojson_client==0.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_ALERT = 'alert' +ATTR_EXTERNAL_ID = 'external_id' +ATTR_MAGNITUDE = 'magnitude' +ATTR_PLACE = 'place' +ATTR_STATUS = 'status' +ATTR_TIME = 'time' +ATTR_TYPE = 'type' +ATTR_UPDATED = 'updated' + +CONF_FEED_TYPE = 'feed_type' +CONF_MINIMUM_MAGNITUDE = 'minimum_magnitude' + +DEFAULT_MINIMUM_MAGNITUDE = 0.0 +DEFAULT_RADIUS_IN_KM = 50.0 +DEFAULT_UNIT_OF_MEASUREMENT = 'km' + +SCAN_INTERVAL = timedelta(minutes=5) + +SIGNAL_DELETE_ENTITY = 'usgs_earthquakes_feed_delete_{}' +SIGNAL_UPDATE_ENTITY = 'usgs_earthquakes_feed_update_{}' + +SOURCE = 'usgs_earthquakes_feed' + +VALID_FEED_TYPES = [ + 'past_hour_significant_earthquakes', + 'past_hour_m45_earthquakes', + 'past_hour_m25_earthquakes', + 'past_hour_m10_earthquakes', + 'past_hour_all_earthquakes', + 'past_day_significant_earthquakes', + 'past_day_m45_earthquakes', + 'past_day_m25_earthquakes', + 'past_day_m10_earthquakes', + 'past_day_all_earthquakes', + 'past_week_significant_earthquakes', + 'past_week_m45_earthquakes', + 'past_week_m25_earthquakes', + 'past_week_m10_earthquakes', + 'past_week_all_earthquakes', + 'past_month_significant_earthquakes', + 'past_month_m45_earthquakes', + 'past_month_m25_earthquakes', + 'past_month_m10_earthquakes', + 'past_month_all_earthquakes', +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_FEED_TYPE): vol.In(VALID_FEED_TYPES), + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float), + vol.Optional(CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE): + vol.All(vol.Coerce(float), vol.Range(min=0)) +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the USGS Earthquake Hazards Program Feed platform.""" + scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + feed_type = config[CONF_FEED_TYPE] + coordinates = (config.get(CONF_LATITUDE, hass.config.latitude), + config.get(CONF_LONGITUDE, hass.config.longitude)) + radius_in_km = config[CONF_RADIUS] + minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE] + # Initialize the entity manager. + feed = UsgsEarthquakesFeedEntityManager( + hass, add_entities, scan_interval, coordinates, feed_type, + radius_in_km, minimum_magnitude) + + def start_feed_manager(event): + """Start feed manager.""" + feed.startup() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + + +class UsgsEarthquakesFeedEntityManager: + """Feed Entity Manager for USGS Earthquake Hazards Program feed.""" + + def __init__(self, hass, add_entities, scan_interval, coordinates, + feed_type, radius_in_km, minimum_magnitude): + """Initialize the Feed Entity Manager.""" + from geojson_client.usgs_earthquake_hazards_program_feed \ + import UsgsEarthquakeHazardsProgramFeedManager + + self._hass = hass + self._feed_manager = UsgsEarthquakeHazardsProgramFeedManager( + self._generate_entity, self._update_entity, self._remove_entity, + coordinates, feed_type, filter_radius=radius_in_km, + filter_minimum_magnitude=minimum_magnitude) + self._add_entities = add_entities + self._scan_interval = scan_interval + + def startup(self): + """Start up this manager.""" + self._feed_manager.update() + self._init_regular_updates() + + def _init_regular_updates(self): + """Schedule regular updates at the specified interval.""" + track_time_interval( + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) + + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) + + def _generate_entity(self, external_id): + """Generate new entity.""" + new_entity = UsgsEarthquakesEvent(self, external_id) + # Add new entities to HA. + self._add_entities([new_entity], True) + + def _update_entity(self, external_id): + """Update entity.""" + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + + def _remove_entity(self, external_id): + """Remove entity.""" + dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + + +class UsgsEarthquakesEvent(GeoLocationEvent): + """This represents an external event with USGS Earthquake data.""" + + def __init__(self, feed_manager, external_id): + """Initialize entity with data from feed entry.""" + self._feed_manager = feed_manager + self._external_id = external_id + self._name = None + self._distance = None + self._latitude = None + self._longitude = None + self._attribution = None + self._place = None + self._magnitude = None + self._time = None + self._updated = None + self._status = None + self._type = None + self._alert = None + self._remove_signal_delete = None + self._remove_signal_update = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self._remove_signal_delete = async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id), + self._delete_callback) + self._remove_signal_update = async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id), + self._update_callback) + + @callback + def _delete_callback(self): + """Remove this entity.""" + self._remove_signal_delete() + self._remove_signal_update() + self.hass.async_create_task(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def should_poll(self): + """No polling needed for USGS Earthquake events.""" + return False + + async def async_update(self): + """Update this entity from the data held in the feed manager.""" + _LOGGER.debug("Updating %s", self._external_id) + feed_entry = self._feed_manager.get_entry(self._external_id) + if feed_entry: + self._update_from_feed(feed_entry) + + def _update_from_feed(self, feed_entry): + """Update the internal state from the provided feed entry.""" + self._name = feed_entry.title + self._distance = feed_entry.distance_to_home + self._latitude = feed_entry.coordinates[0] + self._longitude = feed_entry.coordinates[1] + self._attribution = feed_entry.attribution + self._place = feed_entry.place + self._magnitude = feed_entry.magnitude + self._time = feed_entry.time + self._updated = feed_entry.updated + self._status = feed_entry.status + self._type = feed_entry.type + self._alert = feed_entry.alert + + @property + def source(self) -> str: + """Return source value of this external event.""" + return SOURCE + + @property + def name(self) -> Optional[str]: + """Return the name of the entity.""" + return self._name + + @property + def distance(self) -> Optional[float]: + """Return distance value of this external event.""" + return self._distance + + @property + def latitude(self) -> Optional[float]: + """Return latitude value of this external event.""" + return self._latitude + + @property + def longitude(self) -> Optional[float]: + """Return longitude value of this external event.""" + return self._longitude + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return DEFAULT_UNIT_OF_MEASUREMENT + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + for key, value in ( + (ATTR_EXTERNAL_ID, self._external_id), + (ATTR_PLACE, self._place), + (ATTR_MAGNITUDE, self._magnitude), + (ATTR_TIME, self._time), + (ATTR_UPDATED, self._updated), + (ATTR_STATUS, self._status), + (ATTR_TYPE, self._type), + (ATTR_ALERT, self._alert), + (ATTR_ATTRIBUTION, self._attribution), + ): + if value or isinstance(value, bool): + attributes[key] = value + return attributes diff --git a/homeassistant/components/goalfeed.py b/homeassistant/components/goalfeed.py index 1a571960bc7e5c..c16390302d69a5 100644 --- a/homeassistant/components/goalfeed.py +++ b/homeassistant/components/goalfeed.py @@ -12,8 +12,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -REQUIREMENTS = ['pysher==1.0.4'] - +# Version downgraded due to regression in library +# For details: https://github.com/nlsdfnbch/Pysher/issues/38 +REQUIREMENTS = ['pysher==1.0.1'] DOMAIN = 'goalfeed' CONFIG_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index f444974bc8d802..bf0c72ec1c8e00 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -33,8 +33,6 @@ DEPENDENCIES = ['http'] -DEFAULT_AGENT_USER_ID = 'home-assistant' - ENTITY_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_EXPOSE): cv.boolean, @@ -70,10 +68,12 @@ async def request_sync_service_handler(call: ServiceCall): websession = async_get_clientsession(hass) try: with async_timeout.timeout(5, loop=hass.loop): + agent_user_id = call.data.get('agent_user_id') or \ + call.context.user_id res = await websession.post( REQUEST_SYNC_BASE_URL, params={'key': api_key}, - json={'agent_user_id': call.context.user_id}) + json={'agent_user_id': agent_user_id}) _LOGGER.info("Submitted request_sync request to Google") res.raise_for_status() except aiohttp.ClientResponseError: diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index aca960f9c0aad7..bfeb0fcadf57eb 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -19,8 +19,6 @@ '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} PREFIX_TYPES = 'action.devices.types.' TYPE_LIGHT = PREFIX_TYPES + 'LIGHT' diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index d688491fe8925d..f0294c3bcb23e7 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -48,7 +48,7 @@ def is_exposed(entity) -> bool: entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE) domain_exposed_by_default = \ - expose_by_default or entity.domain in exposed_domains + expose_by_default and 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/services.yaml b/homeassistant/components/google_assistant/services.yaml index 6019b75bd9830a..7d3af71ac2bbab 100644 --- a/homeassistant/components/google_assistant/services.yaml +++ b/homeassistant/components/google_assistant/services.yaml @@ -1,2 +1,5 @@ request_sync: - description: Send a request_sync command to Google. \ No newline at end of file + description: Send a request_sync command to Google. + fields: + agent_user_id: + description: Optional. Only needed for automations. Specific Home Assistant user id to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing. diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 61231a7894de07..7153115e3ef191 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -43,6 +43,7 @@ TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting' TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock' TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed' +TRAIT_MODES = PREFIX_TRAITS + 'Modes' PREFIX_COMMANDS = 'action.devices.commands.' COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff' @@ -59,7 +60,7 @@ COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode' COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock' COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed' - +COMMAND_MODES = PREFIX_COMMANDS + 'SetModes' TRAITS = [] @@ -197,6 +198,8 @@ class OnOffTrait(_Trait): @staticmethod def supported(domain, features): """Test if state is supported.""" + if domain == climate.DOMAIN: + return features & climate.SUPPORT_ON_OFF != 0 return domain in ( group.DOMAIN, input_boolean.DOMAIN, @@ -515,6 +518,9 @@ class TemperatureSettingTrait(_Trait): climate.STATE_COOL: 'cool', climate.STATE_OFF: 'off', climate.STATE_AUTO: 'heatcool', + climate.STATE_FAN_ONLY: 'fan-only', + climate.STATE_DRY: 'dry', + climate.STATE_ECO: 'eco' } google_to_hass = {value: key for key, value in hass_to_google.items()} @@ -585,8 +591,11 @@ async def execute(self, command, params): max_temp = self.state.attributes[climate.ATTR_MAX_TEMP] if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT: - temp = temp_util.convert(params['thermostatTemperatureSetpoint'], - TEMP_CELSIUS, unit) + temp = temp_util.convert( + params['thermostatTemperatureSetpoint'], TEMP_CELSIUS, + unit) + if unit == TEMP_FAHRENHEIT: + temp = round(temp) if temp < min_temp or temp > max_temp: raise SmartHomeError( @@ -604,6 +613,8 @@ async def execute(self, command, params): temp_high = temp_util.convert( params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS, unit) + if unit == TEMP_FAHRENHEIT: + temp_high = round(temp_high) if temp_high < min_temp or temp_high > max_temp: raise SmartHomeError( @@ -612,7 +623,10 @@ async def execute(self, command, params): "{} and {}".format(min_temp, max_temp)) temp_low = temp_util.convert( - params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit) + params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, + unit) + if unit == TEMP_FAHRENHEIT: + temp_low = round(temp_low) if temp_low < min_temp or temp_low > max_temp: raise SmartHomeError( @@ -752,3 +766,188 @@ async def execute(self, command, params): ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] }, blocking=True) + + +@register_trait +class ModesTrait(_Trait): + """Trait to set modes. + + https://developers.google.com/actions/smarthome/traits/modes + """ + + name = TRAIT_MODES + commands = [ + COMMAND_MODES + ] + + # Google requires specific mode names and settings. Here is the full list. + # https://developers.google.com/actions/reference/smarthome/traits/modes + # All settings are mapped here as of 2018-11-28 and can be used for other + # entity types. + + HA_TO_GOOGLE = { + media_player.ATTR_INPUT_SOURCE: "input source", + } + SUPPORTED_MODE_SETTINGS = { + 'xsmall': [ + 'xsmall', 'extra small', 'min', 'minimum', 'tiny', 'xs'], + 'small': ['small', 'half'], + 'large': ['large', 'big', 'full'], + 'xlarge': ['extra large', 'xlarge', 'xl'], + 'Cool': ['cool', 'rapid cool', 'rapid cooling'], + 'Heat': ['heat'], 'Low': ['low'], + 'Medium': ['medium', 'med', 'mid', 'half'], + 'High': ['high'], + 'Auto': ['auto', 'automatic'], + 'Bake': ['bake'], 'Roast': ['roast'], + 'Convection Bake': ['convection bake', 'convect bake'], + 'Convection Roast': ['convection roast', 'convect roast'], + 'Favorite': ['favorite'], + 'Broil': ['broil'], + 'Warm': ['warm'], + 'Off': ['off'], + 'On': ['on'], + 'Normal': [ + 'normal', 'normal mode', 'normal setting', 'standard', + 'schedule', 'original', 'default', 'old settings' + ], + 'None': ['none'], + 'Tap Cold': ['tap cold'], + 'Cold Warm': ['cold warm'], + 'Hot': ['hot'], + 'Extra Hot': ['extra hot'], + 'Eco': ['eco'], + 'Wool': ['wool', 'fleece'], + 'Turbo': ['turbo'], + 'Rinse': ['rinse', 'rinsing', 'rinse wash'], + 'Away': ['away', 'holiday'], + 'maximum': ['maximum'], + 'media player': ['media player'], + 'chromecast': ['chromecast'], + 'tv': [ + 'tv', 'television', 'tv position', 'television position', + 'watching tv', 'watching tv position', 'entertainment', + 'entertainment position' + ], + 'am fm': ['am fm', 'am radio', 'fm radio'], + 'internet radio': ['internet radio'], + 'satellite': ['satellite'], + 'game console': ['game console'], + 'antifrost': ['antifrost', 'anti-frost'], + 'boost': ['boost'], + 'Clock': ['clock'], + 'Message': ['message'], + 'Messages': ['messages'], + 'News': ['news'], + 'Disco': ['disco'], + 'antifreeze': ['antifreeze', 'anti-freeze', 'anti freeze'], + 'balanced': ['balanced', 'normal'], + 'swing': ['swing'], + 'media': ['media', 'media mode'], + 'panic': ['panic'], + 'ring': ['ring'], + 'frozen': ['frozen', 'rapid frozen', 'rapid freeze'], + 'cotton': ['cotton', 'cottons'], + 'blend': ['blend', 'mix'], + 'baby wash': ['baby wash'], + 'synthetics': ['synthetic', 'synthetics', 'compose'], + 'hygiene': ['hygiene', 'sterilization'], + 'smart': ['smart', 'intelligent', 'intelligence'], + 'comfortable': ['comfortable', 'comfort'], + 'manual': ['manual'], + 'energy saving': ['energy saving'], + 'sleep': ['sleep'], + 'quick wash': ['quick wash', 'fast wash'], + 'cold': ['cold'], + 'airsupply': ['airsupply', 'air supply'], + 'dehumidification': ['dehumidication', 'dehumidify'], + 'game': ['game', 'game mode'] + } + + @staticmethod + def supported(domain, features): + """Test if state is supported.""" + if domain != media_player.DOMAIN: + return False + + return features & media_player.SUPPORT_SELECT_SOURCE + + def sync_attributes(self): + """Return mode attributes for a sync request.""" + sources_list = self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST, []) + modes = [] + sources = {} + + if sources_list: + sources = { + "name": self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE), + "name_values": [{ + "name_synonym": ['input source'], + "lang": "en" + }], + "settings": [], + "ordered": False + } + for source in sources_list: + if source in self.SUPPORTED_MODE_SETTINGS: + src = source + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + elif source.lower() in self.SUPPORTED_MODE_SETTINGS: + src = source.lower() + synonyms = self.SUPPORTED_MODE_SETTINGS.get(src) + + else: + continue + + sources['settings'].append( + { + "setting_name": src, + "setting_values": [{ + "setting_synonym": synonyms, + "lang": "en" + }] + } + ) + if sources: + modes.append(sources) + payload = {'availableModes': modes} + + return payload + + def query_attributes(self): + """Return current modes.""" + attrs = self.state.attributes + response = {} + mode_settings = {} + + if attrs.get(media_player.ATTR_INPUT_SOURCE_LIST): + mode_settings.update({ + media_player.ATTR_INPUT_SOURCE: attrs.get( + media_player.ATTR_INPUT_SOURCE) + }) + if mode_settings: + response['on'] = self.state.state != STATE_OFF + response['online'] = True + response['currentModeSettings'] = mode_settings + + return response + + async def execute(self, command, params): + """Execute an SetModes command.""" + settings = params.get('updateModeSettings') + requested_source = settings.get( + self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE)) + + if requested_source: + for src in self.state.attributes.get( + media_player.ATTR_INPUT_SOURCE_LIST): + if src.lower() == requested_source.lower(): + source = src + + await self.hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_SELECT_SOURCE, { + ATTR_ENTITY_ID: self.state.entity_id, + media_player.ATTR_INPUT_SOURCE: source + }, blocking=True) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6bfcaaa5d85ab5..3c058281b0a780 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -213,13 +213,7 @@ async def async_setup(hass, config): embed_iframe=True, ) - # Temporary. No refresh token tells supervisor to use API password. - if hass.auth.active: - token = refresh_token.token - else: - token = None - - await hassio.update_hass_api(config.get('http', {}), token) + await hassio.update_hass_api(config.get('http', {}), refresh_token.token) if 'homeassistant' in config: await hassio.update_hass_timezone(config['homeassistant']) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index c3bd18fa9bbe89..be2806716a7651 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -15,7 +15,6 @@ from aiohttp.hdrs import CONTENT_TYPE from aiohttp.web_exceptions import HTTPBadGateway -from homeassistant.const import CONTENT_TYPE_TEXT_PLAIN from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from .const import X_HASSIO @@ -63,8 +62,6 @@ async def _handle(self, request, path): client = await self._command_proxy(path, request) data = await client.read() - if path.endswith('/logs'): - return _create_response_log(client, data) return _create_response(client, data) get = _handle @@ -114,18 +111,6 @@ def _create_response(client, data): ) -def _create_response_log(client, data): - """Convert a response from client request.""" - # Remove color codes - log = re.sub(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))", "", data.decode()) - - return web.Response( - text=log, - status=client.status, - content_type=CONTENT_TYPE_TEXT_PLAIN, - ) - - def _get_timeout(path): """Return timeout for a URL path.""" if NO_TIMEOUT.match(path): diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py index b5d64f48dc757c..a630a9ef1adb88 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -320,38 +320,39 @@ def _start_cec(event): class CecDevice(Entity): """Representation of a HDMI CEC device entity.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the device.""" self._device = device - self.hass = hass self._icon = None self._state = STATE_UNKNOWN self._logical_address = logical self.entity_id = "%s.%d" % (DOMAIN, self._logical_address) - device.set_update_callback(self._update) def update(self): """Update device status.""" - self._update() + device = self._device + from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ + POWER_OFF, POWER_ON + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + elif device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + else: + _LOGGER.warning("Unknown state: %d", device.power_status) + + async def async_added_to_hass(self): + """Register HDMI callbacks after initialization.""" + self._device.set_update_callback(self._update) def _update(self, device=None): - """Update device status.""" - if device: - from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ - POWER_OFF, POWER_ON - if device.power_status == POWER_OFF: - self._state = STATE_OFF - elif device.status == STATUS_PLAY: - self._state = STATE_PLAYING - elif device.status == STATUS_STOP: - self._state = STATE_IDLE - elif device.status == STATUS_STILL: - self._state = STATE_PAUSED - elif device.power_status == POWER_ON: - self._state = STATE_ON - else: - _LOGGER.warning("Unknown state: %d", device.power_status) - self.schedule_update_ha_state() + """Device status changed, schedule an update.""" + self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index 21d4cdc6e56087..1773a55b3f1bb1 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -38,20 +38,6 @@ IGNORE_DOMAINS = ('zone', 'scene',) -def last_recorder_run(hass): - """Retrieve the last closed recorder run from the database.""" - from homeassistant.components.recorder.models import RecorderRuns - - with session_scope(hass=hass) as session: - res = (session.query(RecorderRuns) - .filter(RecorderRuns.end.isnot(None)) - .order_by(RecorderRuns.end.desc()).first()) - if res is None: - return None - session.expunge(res) - return res - - def get_significant_states(hass, start_time, end_time=None, entity_ids=None, filters=None, include_start_time_state=True): """ diff --git a/homeassistant/components/hlk_sw16.py b/homeassistant/components/hlk_sw16.py new file mode 100644 index 00000000000000..cfbb8ac010c7d5 --- /dev/null +++ b/homeassistant/components/hlk_sw16.py @@ -0,0 +1,163 @@ +""" +Support for HLK-SW16 relay switch. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/hlk_sw16/ +""" +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, CONF_SWITCHES, CONF_NAME) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) + +REQUIREMENTS = ['hlk-sw16==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +DATA_DEVICE_REGISTER = 'hlk_sw16_device_register' +DEFAULT_RECONNECT_INTERVAL = 10 +CONNECTION_TIMEOUT = 10 +DEFAULT_PORT = 8080 + +DOMAIN = 'hlk_sw16' + +SIGNAL_AVAILABILITY = 'hlk_sw16_device_available_{}' + +SWITCH_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, +}) + +RELAY_ID = vol.All( + vol.Any(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 'a', 'b', 'c', 'd', 'e', 'f'), + vol.Coerce(str)) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.string: vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_SWITCHES): vol.Schema({RELAY_ID: SWITCH_SCHEMA}), + }), + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the HLK-SW16 switch.""" + # Allow platform to specify function to register new unknown devices + from hlk_sw16 import create_hlk_sw16_connection + hass.data[DATA_DEVICE_REGISTER] = {} + + def add_device(device): + switches = config[DOMAIN][device][CONF_SWITCHES] + + host = config[DOMAIN][device][CONF_HOST] + port = config[DOMAIN][device][CONF_PORT] + + @callback + def disconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning('HLK-SW16 %s disconnected', device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), + False) + + @callback + def reconnected(): + """Schedule reconnect after connection has been lost.""" + _LOGGER.warning('HLK-SW16 %s connected', device) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), + True) + + async def connect(): + """Set up connection and hook it into HA for reconnect/shutdown.""" + _LOGGER.info('Initiating HLK-SW16 connection to %s', device) + + client = await create_hlk_sw16_connection( + host=host, + port=port, + disconnect_callback=disconnected, + reconnect_callback=reconnected, + loop=hass.loop, + timeout=CONNECTION_TIMEOUT, + reconnect_interval=DEFAULT_RECONNECT_INTERVAL) + + hass.data[DATA_DEVICE_REGISTER][device] = client + + # Load platforms + hass.async_create_task( + async_load_platform(hass, 'switch', DOMAIN, + (switches, device), + config)) + + # handle shutdown of HLK-SW16 asyncio transport + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + lambda x: client.stop()) + + _LOGGER.info('Connected to HLK-SW16 device: %s', device) + + hass.loop.create_task(connect()) + + for device in config[DOMAIN]: + add_device(device) + return True + + +class SW16Device(Entity): + """Representation of a HLK-SW16 device. + + Contains the common logic for HLK-SW16 entities. + """ + + def __init__(self, relay_name, device_port, device_id, client): + """Initialize the device.""" + # HLK-SW16 specific attributes for every component type + self._device_id = device_id + self._device_port = device_port + self._is_on = None + self._client = client + self._name = relay_name + + @callback + def handle_event_callback(self, event): + """Propagate changes through ha.""" + _LOGGER.debug("Relay %s new state callback: %r", + self._device_port, event) + self._is_on = event + self.async_schedule_update_ha_state() + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def name(self): + """Return a name for the device.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return bool(self._client.is_connected) + + @callback + def _availability_callback(self, availability): + """Update availability state.""" + self.async_schedule_update_ha_state() + + async def async_added_to_hass(self): + """Register update callback.""" + self._client.register_status_callback(self.handle_event_callback, + self._device_port) + self._is_on = await self._client.status(self._device_port) + async_dispatcher_connect(self.hass, + SIGNAL_AVAILABILITY.format(self._device_id), + self._availability_callback) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index d53362172217fd..ee99e236fa93ad 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,12 +13,13 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, - CONF_PLATFORM, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) + CONF_PLATFORM, CONF_USERNAME, CONF_SSL, CONF_VERIFY_SSL, + EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.52'] +REQUIREMENTS = ['pyhomematic==0.1.53'] _LOGGER = logging.getLogger(__name__) @@ -77,7 +78,8 @@ 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', - 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage'], + 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage', + 'UniversalSensor'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -173,6 +175,9 @@ DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' DEFAULT_PASSWORD = '' +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = False +DEFAULT_CHANNEL = 1 DEVICE_SCHEMA = vol.Schema({ @@ -180,7 +185,7 @@ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_ADDRESS): cv.string, vol.Required(ATTR_INTERFACE): cv.string, - vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int), + vol.Optional(ATTR_CHANNEL, default=DEFAULT_CHANNEL): vol.Coerce(int), vol.Optional(ATTR_PARAM): cv.string, vol.Optional(ATTR_UNIQUE_ID): cv.string, }) @@ -198,6 +203,9 @@ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, vol.Optional(CONF_CALLBACK_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional( + CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, }}, vol.Optional(CONF_HOSTS, default={}): {cv.match_all: { vol.Required(CONF_HOST): cv.string, @@ -268,6 +276,8 @@ def setup(hass, config): 'password': rconfig.get(CONF_PASSWORD), 'callbackip': rconfig.get(CONF_CALLBACK_IP), 'callbackport': rconfig.get(CONF_CALLBACK_PORT), + 'ssl': rconfig.get(CONF_SSL), + 'verify_ssl': rconfig.get(CONF_VERIFY_SSL), 'connect': True, } diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json index 7cc5943b830781..9ad495c720aafb 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ca.json +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -21,7 +21,7 @@ "title": "Trieu el punt d'acc\u00e9s HomematicIP" }, "link": { - "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Enlla\u00e7ar punt d'acc\u00e9s" } }, diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json index ae67c616f3fd7b..e1aec6162f4c28 100644 --- a/homeassistant/components/homematicip_cloud/.translations/ru.json +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -18,7 +18,7 @@ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0441\u0435\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432)", "pin": "PIN-\u043a\u043e\u0434 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" }, - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 HomematicIP" + "title": "HomematicIP Cloud" }, "link": { "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 7180002430aadd..a6b9588fce3d04 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -200,14 +200,13 @@ def __init__(self, hass, api_password, if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.active and hass.auth.support_legacy: + if hass.auth.support_legacy: _LOGGER.warning( "legacy_api_password support has been enabled. If you don't " "require it, remove the 'api_password' from your http config.") - setup_auth(app, trusted_networks, hass.auth.active, - support_legacy=hass.auth.support_legacy, - api_password=api_password) + setup_auth(app, trusted_networks, + api_password if hass.auth.support_legacy else None) setup_cors(app, cors_origins) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index ae6abf04c02857..6cd211613ce1e3 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -41,29 +41,26 @@ def async_sign_path(hass, refresh_token_id, path, expiration): @callback -def setup_auth(app, trusted_networks, use_auth, - support_legacy=False, api_password=None): +def setup_auth(app, trusted_networks, api_password): """Create auth middleware for the app.""" old_auth_warning = set() - legacy_auth = (not use_auth or support_legacy) and api_password @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" authenticated = False - if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or - DATA_API_PASSWORD in request.query): + if (HTTP_HEADER_HA_AUTH in request.headers or + DATA_API_PASSWORD in request.query): if request.path not in old_auth_warning: _LOGGER.log( - logging.INFO if support_legacy else logging.WARNING, + logging.INFO if api_password else logging.WARNING, 'You need to use a bearer token to access %s from %s', request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) if (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header( - request, api_password if legacy_auth else None)): + await async_validate_auth_header(request, api_password)): # it included both use_auth and api_password Basic auth authenticated = True @@ -73,7 +70,7 @@ async def auth_middleware(request, handler): await async_validate_signed_request(request)): authenticated = True - elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and + elif (api_password and HTTP_HEADER_HA_AUTH in request.headers and hmac.compare_digest( api_password.encode('utf-8'), request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): @@ -82,7 +79,7 @@ async def auth_middleware(request, handler): request['hass_user'] = await legacy_api_password.async_get_user( app['hass']) - elif (legacy_auth and DATA_API_PASSWORD in request.query and + elif (api_password and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): @@ -98,11 +95,6 @@ async def auth_middleware(request, handler): break authenticated = True - elif not use_auth and api_password is None: - # If neither password nor auth_providers set, - # just always set authenticated=True - authenticated = True - request[KEY_AUTHENTICATED] = authenticated return await handler(request) diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 30d4ed0ab8da73..beb5c647266f92 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -9,7 +9,9 @@ import logging from aiohttp import web -from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError +from aiohttp.web_exceptions import ( + HTTPUnauthorized, HTTPInternalServerError, HTTPBadRequest) +import voluptuous as vol from homeassistant.components.http.ban import process_success_login from homeassistant.core import Context, is_callback @@ -45,8 +47,9 @@ def json(self, result, status_code=200, headers=None): """Return a JSON response.""" try: msg = json.dumps( - result, sort_keys=True, cls=JSONEncoder).encode('UTF-8') - except TypeError as err: + result, sort_keys=True, cls=JSONEncoder, allow_nan=False + ).encode('UTF-8') + except (ValueError, TypeError) as err: _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result) raise HTTPInternalServerError response = web.Response( @@ -113,6 +116,10 @@ async def handle(request): if asyncio.iscoroutine(result): result = await result + except vol.Invalid: + raise HTTPBadRequest() + except exceptions.ServiceNotFound: + raise HTTPInternalServerError() except exceptions.Unauthorized: raise HTTPUnauthorized() diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index 4b2581dde658c5..b6e2ccce8ed392 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -3,8 +3,8 @@ "abort": { "all_configured": "\u0412\u0441\u0435 Philips Hue \u0448\u043b\u044e\u0437\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b", "already_configured": "\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.", - "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", - "discover_timeout": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0448\u043b\u044e\u0437\u044b Philips Hue", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "discover_timeout": "\u0428\u043b\u044e\u0437 Philips Hue \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d", "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" }, diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json index 4245ce02c66e14..05d52d5c37e809 100644 --- a/homeassistant/components/hue/.translations/sl.json +++ b/homeassistant/components/hue/.translations/sl.json @@ -24,6 +24,6 @@ "title": "Link Hub" } }, - "title": "Philips Hue Bridge" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json index f93fbe19078adb..aadd66902b6333 100644 --- a/homeassistant/components/ifttt/.translations/ca.json +++ b/homeassistant/components/ifttt/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, necessitareu utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, necessitareu utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/.translations/ko.json b/homeassistant/components/ifttt/.translations/ko.json index 2f033e4f4eeb83..bb54f7ef6cba15 100644 --- a/homeassistant/components/ifttt/.translations/ko.json +++ b/homeassistant/components/ifttt/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n \ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/ifttt/.translations/ru.json b/homeassistant/components/ifttt/.translations/ru.json index 3c1d7b580e4d09..dc846993e2ec0e 100644 --- a/homeassistant/components/ifttt/.translations/ru.json +++ b/homeassistant/components/ifttt/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "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 IFTTT?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 IFTTT Webhook Applet" + "title": "IFTTT Webhook" } }, "title": "IFTTT" diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 9b00f3bd7896da..052921ad37a861 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -9,16 +9,18 @@ import xml.etree.ElementTree import voluptuous as vol - +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA) from homeassistant.components.ihc.const import ( ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, - CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_SENSOR, CONF_SWITCH, - CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_POSITION, + CONF_SENSOR, CONF_SWITCH, CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) from homeassistant.config import load_yaml_config_file from homeassistant.const import ( - CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, - CONF_URL, CONF_USERNAME, TEMP_CELSIUS) + CONF_BINARY_SENSORS, CONF_ID, CONF_LIGHTS, CONF_NAME, CONF_PASSWORD, + CONF_SENSORS, CONF_SWITCHES, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_URL, + CONF_USERNAME, TEMP_CELSIUS) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType @@ -26,21 +28,87 @@ REQUIREMENTS = ['ihcsdk==2.2.0'] DOMAIN = 'ihc' -IHC_DATA = 'ihc' +IHC_DATA = 'ihc{}' IHC_CONTROLLER = 'controller' IHC_INFO = 'info' AUTO_SETUP_YAML = 'ihc_auto_setup.yaml' + +def validate_name(config): + """Validate device name.""" + if CONF_NAME in config: + return config + ihcid = config[CONF_ID] + name = 'ihc_{}'.format(ihcid) + config[CONF_NAME] = name + return config + + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POSITION): cv.string, + vol.Optional(CONF_NOTE): cv.string +}, extra=vol.ALLOW_EXTRA) + + +SWITCH_SCHEMA = DEVICE_SCHEMA.extend({ +}) + +BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_INVERTING, default=False): cv.boolean, +}) + +LIGHT_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, +}) + +SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_UNIT_OF_MEASUREMENT, + default=TEMP_CELSIUS): cv.string, +}) + +IHC_SCHEMA = vol.Schema({ + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, + vol.Optional(CONF_INFO, default=True): cv.boolean, + vol.Optional(CONF_BINARY_SENSORS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + BINARY_SENSOR_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_LIGHTS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + LIGHT_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_SENSORS, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + SENSOR_SCHEMA, + validate_name) + ]), + vol.Optional(CONF_SWITCHES, default=[]): + vol.All(cv.ensure_list, [ + vol.All( + SWITCH_SCHEMA, + validate_name) + ]), +}, extra=vol.ALLOW_EXTRA) + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean, - vol.Optional(CONF_INFO, default=True): cv.boolean, - }), + DOMAIN: vol.Schema(vol.All( + cv.ensure_list, + [IHC_SCHEMA] + )), }, extra=vol.ALLOW_EXTRA) + AUTO_SETUP_SCHEMA = vol.Schema({ vol.Optional(CONF_BINARY_SENSOR, default=[]): vol.All(cv.ensure_list, [ @@ -98,35 +166,79 @@ def setup(hass, config): + """Set up the IHC platform.""" + conf = config.get(DOMAIN) + for index, controller_conf in enumerate(conf): + if not ihc_setup(hass, config, controller_conf, index): + return False + + return True + + +def ihc_setup(hass, config, conf, controller_id): """Set up the IHC component.""" from ihcsdk.ihccontroller import IHCController - conf = config[DOMAIN] + url = conf[CONF_URL] username = conf[CONF_USERNAME] password = conf[CONF_PASSWORD] - ihc_controller = IHCController(url, username, password) + ihc_controller = IHCController(url, username, password) if not ihc_controller.authenticate(): _LOGGER.error("Unable to authenticate on IHC controller") return False if (conf[CONF_AUTOSETUP] and - not autosetup_ihc_products(hass, config, ihc_controller)): + not autosetup_ihc_products(hass, config, ihc_controller, + controller_id)): return False - - hass.data[IHC_DATA] = { + # Manual configuration + get_manual_configuration(hass, config, conf, ihc_controller, + controller_id) + # Store controler configuration + ihc_key = IHC_DATA.format(controller_id) + hass.data[ihc_key] = { IHC_CONTROLLER: ihc_controller, IHC_INFO: conf[CONF_INFO]} - setup_service_functions(hass, ihc_controller) return True -def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): +def get_manual_configuration(hass, config, conf, ihc_controller, + controller_id): + """Get manual configuration for IHC devices.""" + for component in IHC_PLATFORMS: + discovery_info = {} + if component in conf: + component_setup = conf.get(component) + for sensor_cfg in component_setup: + name = sensor_cfg[CONF_NAME] + device = { + 'ihc_id': sensor_cfg[CONF_ID], + 'ctrl_id': controller_id, + 'product': { + 'name': name, + 'note': sensor_cfg.get(CONF_NOTE) or '', + 'position': sensor_cfg.get(CONF_POSITION) or ''}, + 'product_cfg': { + 'type': sensor_cfg.get(CONF_TYPE), + 'inverting': sensor_cfg.get(CONF_INVERTING), + 'dimmable': sensor_cfg.get(CONF_DIMMABLE), + 'unit': sensor_cfg.get(CONF_UNIT_OF_MEASUREMENT) + } + } + discovery_info[name] = device + if discovery_info: + discovery.load_platform(hass, component, DOMAIN, + discovery_info, config) + + +def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller, + controller_id): """Auto setup of IHC products from the IHC project file.""" project_xml = ihc_controller.get_project() if not project_xml: - _LOGGER.error("Unable to read project from ICH controller") + _LOGGER.error("Unable to read project from IHC controller") return False project = xml.etree.ElementTree.fromstring(project_xml) @@ -143,14 +255,15 @@ def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller): groups = project.findall('.//group') for component in IHC_PLATFORMS: component_setup = auto_setup_conf[component] - discovery_info = get_discovery_info(component_setup, groups) + discovery_info = get_discovery_info(component_setup, groups, + controller_id) if discovery_info: discovery.load_platform(hass, component, DOMAIN, discovery_info, config) return True -def get_discovery_info(component_setup, groups): +def get_discovery_info(component_setup, groups, controller_id): """Get discovery info for specified IHC component.""" discovery_data = {} for group in groups: @@ -167,6 +280,7 @@ def get_discovery_info(component_setup, groups): name = '{}_{}'.format(groupname, ihc_id) device = { 'ihc_id': ihc_id, + 'ctrl_id': controller_id, 'product': { 'name': product.attrib['name'], 'note': product.attrib['note'], @@ -205,13 +319,3 @@ def set_runtime_value_float(call): hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) - - -def validate_name(config): - """Validate device name.""" - if CONF_NAME in config: - return config - ihcid = config[CONF_ID] - name = 'ihc_{}'.format(ihcid) - config[CONF_NAME] = name - return config diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index b06746c8e7aef1..d6e4d0e0d4d933 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -10,6 +10,9 @@ CONF_LIGHT = 'light' CONF_SENSOR = 'sensor' CONF_SWITCH = 'switch' +CONF_NAME = 'name' +CONF_POSITION = 'position' +CONF_NOTE = 'note' ATTR_IHC_ID = 'ihc_id' ATTR_VALUE = 'value' diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 84d92361541021..72a4a8155e2911 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -76,10 +76,14 @@ async def async_scan_service(service): """Service handler for scan.""" image_entities = component.async_extract_from_service(service) - update_task = [entity.async_update_ha_state(True) for - entity in image_entities] - if update_task: - await asyncio.wait(update_task, loop=hass.loop) + update_tasks = [] + for entity in image_entities: + entity.async_set_context(service.context) + update_tasks.append( + entity.async_update_ha_state(True)) + + if update_tasks: + await asyncio.wait(update_tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index 8f5b599bb884d4..6172963525e187 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.2.0', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.15.4', 'pillow==5.3.0', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py index 6d54324542add0..dfb41ddf617ba5 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -23,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_values import EntityValues -REQUIREMENTS = ['influxdb==5.0.0'] +REQUIREMENTS = ['influxdb==5.2.0'] _LOGGER = logging.getLogger(__name__) @@ -136,7 +136,7 @@ def setup(hass, config): try: influx = InfluxDBClient(**kwargs) - influx.query("SHOW SERIES LIMIT 1;", database=conf[CONF_DB_NAME]) + influx.write_points([]) except (exceptions.InfluxDBClientError, requests.exceptions.ConnectionError) as exc: _LOGGER.error("Database host is not accessible due to '%s', please " diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py index 18c9808c6d20aa..541e38202fc14a 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity DOMAIN = 'input_boolean' @@ -84,7 +84,7 @@ async def async_setup(hass, config): return True -class InputBoolean(ToggleEntity): +class InputBoolean(ToggleEntity, RestoreEntity): """Representation of a boolean input.""" def __init__(self, object_id, name, initial, icon): @@ -117,10 +117,11 @@ def is_on(self): async def async_added_to_hass(self): """Call when entity about to be added to hass.""" # If not None, we got an initial value. + await super().async_added_to_hass() if self._state is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() self._state = state and state.state == STATE_ON async def async_turn_on(self, **kwargs): diff --git a/homeassistant/components/input_datetime.py b/homeassistant/components/input_datetime.py index df35ae53ba9fc3..6ac9a24d0444aa 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -11,9 +11,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -97,7 +96,7 @@ async def async_set_datetime_service(entity, call): return True -class InputDatetime(Entity): +class InputDatetime(RestoreEntity): """Representation of a datetime input.""" def __init__(self, object_id, name, has_date, has_time, icon, initial): @@ -112,6 +111,7 @@ def __init__(self, object_id, name, has_date, has_time, icon, initial): async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() restore_val = None # Priority 1: Initial State @@ -120,7 +120,7 @@ async def async_added_to_hass(self): # Priority 2: Old state if restore_val is None: - old_state = await async_get_last_state(self.hass, self.entity_id) + old_state = await self.async_get_last_state() if old_state is not None: restore_val = old_state.state diff --git a/homeassistant/components/input_number.py b/homeassistant/components/input_number.py index f52b9add82162d..b6c6eab3cf5a05 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -11,9 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -123,7 +122,7 @@ async def async_setup(hass, config): return True -class InputNumber(Entity): +class InputNumber(RestoreEntity): """Representation of a slider.""" def __init__(self, object_id, name, initial, minimum, maximum, step, icon, @@ -178,10 +177,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" + await super().async_added_to_hass() if self._current_value is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() value = state and float(state.state) # Check against None because value can be 0 diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py index b8398e1be3d81c..cc9a73bf91549b 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -10,9 +10,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -116,7 +115,7 @@ async def async_setup(hass, config): return True -class InputSelect(Entity): +class InputSelect(RestoreEntity): """Representation of a select input.""" def __init__(self, object_id, name, initial, options, icon): @@ -129,10 +128,11 @@ def __init__(self, object_id, name, initial, options, icon): async def async_added_to_hass(self): """Run when entity about to be added.""" + await super().async_added_to_hass() if self._current_option is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() if not state or state.state not in self._options: self._current_option = self._options[0] else: diff --git a/homeassistant/components/input_text.py b/homeassistant/components/input_text.py index 956d9a6466d7cf..8ac64b398f4bc7 100644 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -11,9 +11,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -104,7 +103,7 @@ async def async_setup(hass, config): return True -class InputText(Entity): +class InputText(RestoreEntity): """Represent a text box.""" def __init__(self, object_id, name, initial, minimum, maximum, icon, @@ -157,10 +156,11 @@ def state_attributes(self): async def async_added_to_hass(self): """Run when entity about to be added to hass.""" + await super().async_added_to_hass() if self._current_value is not None: return - state = await async_get_last_state(self.hass, self.entity_id) + state = await self.async_get_last_state() value = state and state.state # Check against None because value can be 0 diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json index 95d675076daa48..a311daa6f9ea5b 100644 --- a/homeassistant/components/ios/.translations/cs.json +++ b/homeassistant/components/ios/.translations/cs.json @@ -2,6 +2,7 @@ "config": { "step": { "confirm": { + "description": "Chcete nastavit komponenty Home Assistant iOS?", "title": "Home Assistant iOS" } }, diff --git a/homeassistant/components/lifx/.translations/hu.json b/homeassistant/components/lifx/.translations/hu.json index c78905b09c8f95..255b2efc91a079 100644 --- a/homeassistant/components/lifx/.translations/hu.json +++ b/homeassistant/components/lifx/.translations/hu.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k LIFX eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen LIFX konfigur\u00e1ci\u00f3 lehets\u00e9ges." + }, "step": { "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a LIFX-t?", "title": "LIFX" } }, diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 52df3d47ca102c..f2713197ed12bb 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ DOMAIN = 'lifx' -REQUIREMENTS = ['aiolifx==0.6.6'] +REQUIREMENTS = ['aiolifx==0.6.7'] CONF_SERVER = 'server' CONF_BROADCAST = 'broadcast' diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index da7ccfb2db2585..b9c575dbd5a1b2 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -40,6 +40,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount + from decora_wifi.models.residence import Residence email = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) @@ -60,8 +61,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): perms = session.user.get_residential_permissions() all_switches = [] for permission in perms: - acct = ResidentialAccount(session, permission.residentialAccountId) - for residence in acct.get_residences(): + if permission.residentialAccountId is not None: + acct = ResidentialAccount( + session, permission.residentialAccountId) + for residence in acct.get_residences(): + for switch in residence.get_iot_switches(): + all_switches.append(switch) + elif permission.residenceId is not None: + residence = Residence(session, permission.residenceId) for switch in residence.get_iot_switches(): all_switches.append(switch) diff --git a/homeassistant/components/light/fibaro.py b/homeassistant/components/light/fibaro.py index 7157dcfd31b347..636e4376ae28c3 100644 --- a/homeassistant/components/light/fibaro.py +++ b/homeassistant/components/light/fibaro.py @@ -27,7 +27,7 @@ def scaleto255(value): # depending on device type (e.g. dimmer vs led) if value > 98: value = 100 - return max(0, min(255, ((value * 256.0) / 100.0))) + return max(0, min(255, ((value * 255.0) / 100.0))) def scaleto100(value): @@ -35,7 +35,7 @@ def scaleto100(value): # 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))) + return max(0, min(100, ((value * 100.0) / 255.0))) async def async_setup_platform(hass, @@ -122,11 +122,11 @@ def _turn_on(self, **kwargs): 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)) + round(rgb[0] * self._brightness / 100.0), + round(rgb[1] * self._brightness / 100.0), + round(rgb[2] * self._brightness / 100.0), + round(self._white * self._brightness / 100.0)) + if self.state == 'off': self.set_level(int(self._brightness)) return @@ -168,6 +168,10 @@ def _update(self): # Brightness handling if self._supported_flags & SUPPORT_BRIGHTNESS: self._brightness = float(self.fibaro_device.properties.value) + # Fibaro might report 0-99 or 0-100 for brightness, + # based on device type, so we round up here + if self._brightness > 99: + self._brightness = 100 # Color handling if self._supported_flags & SUPPORT_COLOR and \ 'color' in self.fibaro_device.properties and \ diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py index da90a53c84804e..f80c9b2fd6fd1a 100644 --- a/homeassistant/components/light/ihc.py +++ b/homeassistant/components/light/ihc.py @@ -3,53 +3,39 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.ihc/ """ -import voluptuous as vol +import logging from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) -from homeassistant.components.ihc.const import CONF_DIMMABLE + IHC_DATA, IHC_CONTROLLER, IHC_INFO) +from homeassistant.components.ihc.const import ( + CONF_DIMMABLE) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.light import ( - ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA, Light) -from homeassistant.const import CONF_ID, CONF_NAME, CONF_LIGHTS -import homeassistant.helpers.config_validation as cv + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_LIGHTS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, - }, validate_name) - ]) -}) +_LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC lights platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - light = IhcLight(ihc_controller, name, ihc_id, info, - product_cfg[CONF_DIMMABLE], product) - devices.append(light) - else: - lights = config[CONF_LIGHTS] - for light in lights: - ihc_id = light[CONF_ID] - name = light[CONF_NAME] - dimmable = light[CONF_DIMMABLE] - device = IhcLight(ihc_controller, name, ihc_id, info, dimmable) - devices.append(device) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + dimmable = product_cfg[CONF_DIMMABLE] + light = IhcLight(ihc_controller, name, ihc_id, info, + dimmable, product) + devices.append(light) add_entities(devices) diff --git a/homeassistant/components/light/lightwave.py b/homeassistant/components/light/lightwave.py new file mode 100644 index 00000000000000..50c664d90463d8 --- /dev/null +++ b/homeassistant/components/light/lightwave.py @@ -0,0 +1,88 @@ +""" +Implements LightwaveRF lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.lightwave/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) +from homeassistant.components.lightwave import LIGHTWAVE_LINK +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['lightwave'] + +MAX_BRIGHTNESS = 255 + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Find and return LightWave lights.""" + if not discovery_info: + return + + lights = [] + lwlink = hass.data[LIGHTWAVE_LINK] + + for device_id, device_config in discovery_info.items(): + name = device_config[CONF_NAME] + lights.append(LWRFLight(name, device_id, lwlink)) + + async_add_entities(lights) + + +class LWRFLight(Light): + """Representation of a LightWaveRF light.""" + + def __init__(self, name, device_id, lwlink): + """Initialize LWRFLight entity.""" + self._name = name + self._device_id = device_id + self._state = None + self._brightness = MAX_BRIGHTNESS + self._lwlink = lwlink + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + @property + def should_poll(self): + """No polling needed for a LightWave light.""" + return False + + @property + def name(self): + """Lightwave light name.""" + return self._name + + @property + def brightness(self): + """Brightness of this light between 0..MAX_BRIGHTNESS.""" + return self._brightness + + @property + def is_on(self): + """Lightwave light is on state.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the LightWave light on.""" + self._state = True + + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs[ATTR_BRIGHTNESS] + + if self._brightness != MAX_BRIGHTNESS: + self._lwlink.turn_on_with_brightness( + self._device_id, self._name, self._brightness) + else: + self._lwlink.turn_on_light(self._device_id, self._name) + + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the LightWave light off.""" + self._state = False + self._lwlink.turn_off(self._device_id, self._name) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index 2e2971cfdc267b..3a0225d8d650d0 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -18,7 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.color import ( color_temperature_mired_to_kelvin, color_hs_to_RGB) -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity REQUIREMENTS = ['limitlessled==1.1.3'] @@ -157,7 +157,7 @@ def wrapper(self, **kwargs): return decorator -class LimitlessLEDGroup(Light): +class LimitlessLEDGroup(Light, RestoreEntity): """Representation of a LimitessLED group.""" def __init__(self, group, config): @@ -189,7 +189,8 @@ def __init__(self, group, config): async def async_added_to_hass(self): """Handle entity about to be added to hass event.""" - last_state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + last_state = await self.async_get_last_state() if last_state: self._is_on = (last_state.state == STATE_ON) self._brightness = last_state.attributes.get('brightness') diff --git a/homeassistant/components/light/lutron.py b/homeassistant/components/light/lutron.py index 6c4047e2314043..ee08e532ce73ec 100644 --- a/homeassistant/components/light/lutron.py +++ b/homeassistant/components/light/lutron.py @@ -24,7 +24,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devs.append(dev) add_entities(devs, True) - return True def to_lutron_level(level): @@ -43,7 +42,7 @@ class LutronLight(LutronDevice, Light): def __init__(self, area_name, lutron_device, controller): """Initialize the light.""" self._prev_brightness = None - LutronDevice.__init__(self, area_name, lutron_device, controller) + super().__init__(self, area_name, lutron_device, controller) @property def supported_features(self): @@ -77,7 +76,7 @@ def turn_off(self, **kwargs): def device_state_attributes(self): """Return the state attributes.""" attr = {} - attr['Lutron Integration ID'] = self._lutron_device.id + attr['lutron_integration_id'] = self._lutron_device.id return attr @property diff --git a/homeassistant/components/light/mqtt/__init__.py b/homeassistant/components/light/mqtt/__init__.py new file mode 100644 index 00000000000000..93f32cd27918e8 --- /dev/null +++ b/homeassistant/components/light/mqtt/__init__.py @@ -0,0 +1,72 @@ +""" +Support for MQTT lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.mqtt/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components import light +from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH +from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType, ConfigType + +from . import schema_basic +from . import schema_json +from . import schema_template + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['mqtt'] + +CONF_SCHEMA = 'schema' + + +def validate_mqtt_light(value): + """Validate MQTT light schema.""" + schemas = { + 'basic': schema_basic.PLATFORM_SCHEMA_BASIC, + 'json': schema_json.PLATFORM_SCHEMA_JSON, + 'template': schema_template.PLATFORM_SCHEMA_TEMPLATE, + } + return schemas[value[CONF_SCHEMA]](value) + + +PLATFORM_SCHEMA = vol.All(vol.Schema({ + vol.Optional(CONF_SCHEMA, default='basic'): vol.All( + vol.Lower, vol.Any('basic', 'json', 'template')) +}, extra=vol.ALLOW_EXTRA), validate_mqtt_light) + + +async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_info=None): + """Set up MQTT light 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 light dynamically through MQTT discovery.""" + async def async_discover(discovery_payload): + """Discover and add a MQTT light.""" + 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(light.DOMAIN, 'mqtt'), + async_discover) + + +async def _async_setup_entity(hass, config, async_add_entities, + discovery_hash=None): + """Set up a MQTT Light.""" + setup_entity = { + 'basic': schema_basic.async_setup_entity_basic, + 'json': schema_json.async_setup_entity_json, + 'template': schema_template.async_setup_entity_template, + } + await setup_entity[config['schema']]( + hass, config, async_add_entities, discovery_hash) diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt/schema_basic.py similarity index 77% rename from homeassistant/components/light/mqtt.py rename to homeassistant/components/light/mqtt/schema_basic.py index 92030c8617a377..74f3dbdec91aff 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.components import mqtt, light +from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, @@ -19,13 +19,10 @@ CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate) -from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW -from homeassistant.helpers.restore_state import async_get_last_state -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.typing import HomeAssistantType, ConfigType + CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + MqttAvailability, MqttDiscoveryUpdate, subscription) +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -72,7 +69,7 @@ VALUES_ON_COMMAND_TYPE = ['first', 'last', 'brightness'] -PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_BASIC = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -111,36 +108,73 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info=None): - """Set up MQTT light through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) +async def async_setup_entity_basic(hass, config, async_add_entities, + discovery_hash=None): + """Set up a MQTT Light.""" + config.setdefault( + CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) + async_add_entities([MqttLight(config, discovery_hash)]) -async def async_setup_entry(hass, config_entry, async_add_entities): - """Set up MQTT light dynamically through MQTT discovery.""" - async def async_discover(discovery_payload): - """Discover and add a MQTT light.""" - 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(light.DOMAIN, 'mqtt'), - async_discover) +class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light, RestoreEntity): + """Representation of a MQTT light.""" + def __init__(self, config, discovery_hash): + """Initialize MQTT light.""" + self._state = False + self._sub_state = None + self._brightness = None + self._hs = None + self._color_temp = None + self._effect = None + self._white_value = None + self._supported_features = 0 -async def _async_setup_entity(hass, config, async_add_entities, - discovery_hash=None): - """Set up a MQTT Light.""" - config.setdefault( - CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) + self._topic = None + self._payload = None + self._templates = None + self._optimistic = False + self._optimistic_rgb = False + self._optimistic_brightness = False + self._optimistic_color_temp = False + self._optimistic_effect = False + self._optimistic_hs = False + self._optimistic_white_value = False + self._optimistic_xy = False + self._unique_id = config.get(CONF_UNIQUE_ID) + + # 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) + qos = config.get(CONF_QOS) + + MqttAvailability.__init__(self, availability_topic, qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) - async_add_entities([MqttLight( - config.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_EFFECT_LIST), - { + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_BASIC(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._config = config + + topic = { key: config.get(key) for key in ( CONF_BRIGHTNESS_COMMAND_TOPIC, CONF_BRIGHTNESS_STATE_TOPIC, @@ -159,8 +193,13 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_XY_COMMAND_TOPIC, CONF_XY_STATE_TOPIC, ) - }, - { + } + self._topic = topic + self._payload = { + 'on': config.get(CONF_PAYLOAD_ON), + 'off': config.get(CONF_PAYLOAD_OFF), + } + self._templates = { CONF_BRIGHTNESS: config.get(CONF_BRIGHTNESS_VALUE_TEMPLATE), CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE), CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE), @@ -170,43 +209,9 @@ async def _async_setup_entity(hass, config, async_add_entities, CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE), CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE), CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE), - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - { - 'on': config.get(CONF_PAYLOAD_ON), - 'off': config.get(CONF_PAYLOAD_OFF), - }, - config.get(CONF_OPTIMISTIC), - config.get(CONF_BRIGHTNESS_SCALE), - config.get(CONF_WHITE_VALUE_SCALE), - config.get(CONF_ON_COMMAND_TYPE), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - discovery_hash, - )]) - - -class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): - """Representation of a MQTT light.""" + } - def __init__(self, name, unique_id, effect_list, topic, templates, - qos, retain, payload, optimistic, brightness_scale, - white_value_scale, on_command_type, availability_topic, - payload_available, payload_not_available, discovery_hash): - """Initialize MQTT light.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self._name = name - self._unique_id = unique_id - self._effect_list = effect_list - self._topic = topic - self._qos = qos - self._retain = retain - self._payload = payload - self._templates = templates + optimistic = config.get(CONF_OPTIMISTIC) self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None self._optimistic_rgb = \ optimistic or topic[CONF_RGB_STATE_TOPIC] is None @@ -226,15 +231,7 @@ def __init__(self, name, unique_id, effect_list, topic, templates, optimistic or topic[CONF_WHITE_VALUE_STATE_TOPIC] is None) self._optimistic_xy = \ optimistic or topic[CONF_XY_STATE_TOPIC] is None - self._brightness_scale = brightness_scale - self._white_value_scale = white_value_scale - self._on_command_type = on_command_type - self._state = False - self._brightness = None - self._hs = None - self._color_temp = None - self._effect = None - self._white_value = None + self._supported_features = 0 self._supported_features |= ( topic[CONF_RGB_COMMAND_TOPIC] is not None and @@ -255,13 +252,10 @@ def __init__(self, name, unique_id, effect_list, topic, templates, SUPPORT_WHITE_VALUE) self._supported_features |= ( topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - self._discovery_hash = discovery_hash - - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + topics = {} templates = {} for key, tpl in list(self._templates.items()): if tpl is None: @@ -270,7 +264,7 @@ async def async_added_to_hass(self): tpl.hass = self.hass templates[key] = tpl.async_render_with_possible_json_value - last_state = await async_get_last_state(self.hass, self.entity_id) + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): @@ -287,9 +281,10 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + topics[CONF_STATE_TOPIC] = { + 'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._config.get(CONF_QOS)} elif self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -303,14 +298,16 @@ def brightness_received(topic, payload, qos): return device_value = float(payload) - percent_bright = device_value / self._brightness_scale + percent_bright = \ + device_value / self._config.get(CONF_BRIGHTNESS_SCALE) self._brightness = int(percent_bright * 255) self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_BRIGHTNESS_STATE_TOPIC], - brightness_received, self._qos) + topics[CONF_BRIGHTNESS_STATE_TOPIC] = { + 'topic': self._topic[CONF_BRIGHTNESS_STATE_TOPIC], + 'msg_callback': brightness_received, + 'qos': self._config.get(CONF_QOS)} self._brightness = 255 elif self._optimistic_brightness and last_state\ and last_state.attributes.get(ATTR_BRIGHTNESS): @@ -337,9 +334,10 @@ def rgb_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_RGB_STATE_TOPIC], rgb_received, - self._qos) + topics[CONF_RGB_STATE_TOPIC] = { + 'topic': self._topic[CONF_RGB_STATE_TOPIC], + 'msg_callback': rgb_received, + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_rgb and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -360,9 +358,10 @@ def color_temp_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_COLOR_TEMP_STATE_TOPIC], - color_temp_received, self._qos) + topics[CONF_COLOR_TEMP_STATE_TOPIC] = { + 'topic': self._topic[CONF_COLOR_TEMP_STATE_TOPIC], + 'msg_callback': color_temp_received, + 'qos': self._config.get(CONF_QOS)} self._color_temp = 150 if self._optimistic_color_temp and last_state\ and last_state.attributes.get(ATTR_COLOR_TEMP): @@ -384,9 +383,10 @@ def effect_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_EFFECT_STATE_TOPIC], - effect_received, self._qos) + topics[CONF_EFFECT_STATE_TOPIC] = { + 'topic': self._topic[CONF_EFFECT_STATE_TOPIC], + 'msg_callback': effect_received, + 'qos': self._config.get(CONF_QOS)} self._effect = 'none' if self._optimistic_effect and last_state\ and last_state.attributes.get(ATTR_EFFECT): @@ -413,9 +413,10 @@ def hs_received(topic, payload, qos): payload) if self._topic[CONF_HS_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_HS_STATE_TOPIC], hs_received, - self._qos) + topics[CONF_HS_STATE_TOPIC] = { + 'topic': self._topic[CONF_HS_STATE_TOPIC], + 'msg_callback': hs_received, + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_hs and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -433,14 +434,16 @@ def white_value_received(topic, payload, qos): return device_value = float(payload) - percent_white = device_value / self._white_value_scale + percent_white = \ + device_value / self._config.get(CONF_WHITE_VALUE_SCALE) self._white_value = int(percent_white * 255) self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_WHITE_VALUE_STATE_TOPIC], - white_value_received, self._qos) + topics[CONF_WHITE_VALUE_STATE_TOPIC] = { + 'topic': self._topic[CONF_WHITE_VALUE_STATE_TOPIC], + 'msg_callback': white_value_received, + 'qos': self._config.get(CONF_QOS)} self._white_value = 255 elif self._optimistic_white_value and last_state\ and last_state.attributes.get(ATTR_WHITE_VALUE): @@ -464,9 +467,10 @@ def xy_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_XY_STATE_TOPIC], xy_received, - self._qos) + topics[CONF_XY_STATE_TOPIC] = { + 'topic': self._topic[CONF_XY_STATE_TOPIC], + 'msg_callback': xy_received, + 'qos': self._config.get(CONF_QOS)} self._hs = (0, 0) if self._optimistic_xy and last_state\ and last_state.attributes.get(ATTR_HS_COLOR): @@ -474,6 +478,15 @@ def xy_received(topic, payload, qos): elif self._topic[CONF_XY_COMMAND_TOPIC] is not None: self._hs = (0, 0) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + topics) + + 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 brightness(self): """Return the brightness of this light between 0..255.""" @@ -502,7 +515,7 @@ def should_poll(self): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._config.get(CONF_NAME) @property def unique_id(self): @@ -522,7 +535,7 @@ def assumed_state(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def effect(self): @@ -540,17 +553,19 @@ async def async_turn_on(self, **kwargs): This method is a coroutine. """ should_update = False + on_command_type = self._config.get(CONF_ON_COMMAND_TYPE) - if self._on_command_type == 'first': + if on_command_type == 'first': mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload['on'], self._qos, self._retain) + self._payload['on'], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) should_update = True # If brightness is being used instead of an on command, make sure # there is a brightness input. Either set the brightness to our # saved value or the maximum value if this is the first call - elif self._on_command_type == 'brightness': + elif on_command_type == 'brightness': if ATTR_BRIGHTNESS not in kwargs: kwargs[ATTR_BRIGHTNESS] = self._brightness if \ self._brightness else 255 @@ -582,7 +597,8 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, self._qos, self._retain) + rgb_color_str, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_rgb: self._hs = kwargs[ATTR_HS_COLOR] @@ -594,8 +610,8 @@ async def async_turn_on(self, **kwargs): hs_color = kwargs[ATTR_HS_COLOR] mqtt.async_publish( self.hass, self._topic[CONF_HS_COMMAND_TOPIC], - '{},{}'.format(*hs_color), self._qos, - self._retain) + '{},{}'.format(*hs_color), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_hs: self._hs = kwargs[ATTR_HS_COLOR] @@ -607,8 +623,8 @@ async def async_turn_on(self, **kwargs): xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) mqtt.async_publish( self.hass, self._topic[CONF_XY_COMMAND_TOPIC], - '{},{}'.format(*xy_color), self._qos, - self._retain) + '{},{}'.format(*xy_color), self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_xy: self._hs = kwargs[ATTR_HS_COLOR] @@ -617,10 +633,12 @@ async def async_turn_on(self, **kwargs): if ATTR_BRIGHTNESS in kwargs and \ self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255 - device_brightness = int(percent_bright * self._brightness_scale) + device_brightness = \ + int(percent_bright * self._config.get(CONF_BRIGHTNESS_SCALE)) mqtt.async_publish( self.hass, self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC], - device_brightness, self._qos, self._retain) + device_brightness, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_brightness: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -641,7 +659,8 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_RGB_COMMAND_TOPIC], - rgb_color_str, self._qos, self._retain) + rgb_color_str, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_brightness: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -652,7 +671,8 @@ async def async_turn_on(self, **kwargs): color_temp = int(kwargs[ATTR_COLOR_TEMP]) mqtt.async_publish( self.hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC], - color_temp, self._qos, self._retain) + color_temp, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_color_temp: self._color_temp = kwargs[ATTR_COLOR_TEMP] @@ -661,10 +681,11 @@ async def async_turn_on(self, **kwargs): if ATTR_EFFECT in kwargs and \ self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: effect = kwargs[ATTR_EFFECT] - if effect in self._effect_list: + if effect in self._config.get(CONF_EFFECT_LIST): mqtt.async_publish( self.hass, self._topic[CONF_EFFECT_COMMAND_TOPIC], - effect, self._qos, self._retain) + effect, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_effect: self._effect = kwargs[ATTR_EFFECT] @@ -673,22 +694,25 @@ async def async_turn_on(self, **kwargs): if ATTR_WHITE_VALUE in kwargs and \ self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None: percent_white = float(kwargs[ATTR_WHITE_VALUE]) / 255 - device_white_value = int(percent_white * self._white_value_scale) + device_white_value = \ + int(percent_white * self._config.get(CONF_WHITE_VALUE_SCALE)) mqtt.async_publish( self.hass, self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC], - device_white_value, self._qos, self._retain) + device_white_value, self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic_white_value: self._white_value = kwargs[ATTR_WHITE_VALUE] should_update = True - if self._on_command_type == 'last': + if on_command_type == 'last': mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC], - self._payload['on'], self._qos, self._retain) + self._payload['on'], self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) should_update = True if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the light has changed state. self._state = True should_update = True @@ -702,9 +726,9 @@ async def async_turn_off(self, **kwargs): """ mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['off'], - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the light has changed state. self._state = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt/schema_json.py similarity index 74% rename from homeassistant/components/light/mqtt_json.py rename to homeassistant/components/light/mqtt/schema_json.py index 1ed43a6385a65d..8a72f7b1f89869 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -6,7 +6,6 @@ """ import json import logging -from typing import Optional import voluptuous as vol @@ -14,23 +13,23 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, FLASH_LONG, FLASH_SHORT, - PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, - SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, - Light) -from homeassistant.components.light.mqtt import CONF_BRIGHTNESS_SCALE + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, + SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light) from homeassistant.components.mqtt import ( - ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, - CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - CONF_STATE_TOPIC, MqttAvailability, MqttDiscoveryUpdate) + CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, + CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, + MqttAvailability, MqttDiscoveryUpdate, subscription) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.color as color_util +from .schema_basic import CONF_BRIGHTNESS_SCALE + _LOGGER = logging.getLogger(__name__) DOMAIN = 'mqtt_json' @@ -58,7 +57,7 @@ CONF_UNIQUE_ID = 'unique_id' # Stealing some of these from the base MQTT configs. -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -84,116 +83,119 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_info=None): +async def async_setup_entity_json(hass: HomeAssistantType, config: ConfigType, + async_add_entities, discovery_hash): """Set up a MQTT JSON Light.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - 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([MqttJson( - config.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_EFFECT_LIST), - { - key: config.get(key) for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC - ) - }, - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_OPTIMISTIC), - config.get(CONF_BRIGHTNESS), - config.get(CONF_COLOR_TEMP), - config.get(CONF_EFFECT), - config.get(CONF_RGB), - config.get(CONF_WHITE_VALUE), - config.get(CONF_XY), - config.get(CONF_HS), - { - key: config.get(key) for key in ( - CONF_FLASH_TIME_SHORT, - CONF_FLASH_TIME_LONG - ) - }, - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_BRIGHTNESS_SCALE), - discovery_hash, - )]) + async_add_entities([MqttLightJson(config, discovery_hash)]) -class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): +class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, + RestoreEntity): """Representation of a MQTT JSON light.""" - def __init__(self, name, unique_id, effect_list, topic, qos, retain, - optimistic, brightness, color_temp, effect, rgb, white_value, - xy, hs, flash_times, availability_topic, payload_available, - payload_not_available, brightness_scale, - discovery_hash: Optional[str]): + def __init__(self, config, discovery_hash): """Initialize MQTT JSON light.""" + self._state = False + self._sub_state = None + self._supported_features = 0 + + self._topic = None + self._optimistic = False + self._brightness = None + self._color_temp = None + self._effect = None + self._hs = None + self._white_value = None + self._flash_times = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # 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) + qos = config.get(CONF_QOS) + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - self._name = name - self._unique_id = unique_id - self._effect_list = effect_list - self._topic = topic - self._qos = qos - self._retain = retain - self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None - self._state = False - self._rgb = rgb - self._xy = xy - self._hs_support = hs + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_JSON(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._config = config + + self._topic = { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC + ) + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + + brightness = config.get(CONF_BRIGHTNESS) if brightness: self._brightness = 255 else: self._brightness = None + color_temp = config.get(CONF_COLOR_TEMP) if color_temp: self._color_temp = 150 else: self._color_temp = None + effect = config.get(CONF_EFFECT) if effect: self._effect = 'none' else: self._effect = None - if hs or rgb or xy: - self._hs = [0, 0] - else: - self._hs = None - + white_value = config.get(CONF_WHITE_VALUE) if white_value: self._white_value = 255 else: self._white_value = None - self._flash_times = flash_times - self._brightness_scale = brightness_scale + if config.get(CONF_HS) or config.get(CONF_RGB) or config.get(CONF_XY): + self._hs = [0, 0] + else: + self._hs = None + + self._flash_times = { + key: config.get(key) for key in ( + CONF_FLASH_TIME_SHORT, + CONF_FLASH_TIME_LONG + ) + } self._supported_features = (SUPPORT_TRANSITION | SUPPORT_FLASH) - self._supported_features |= (rgb and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_RGB) and SUPPORT_COLOR) self._supported_features |= (brightness and SUPPORT_BRIGHTNESS) self._supported_features |= (color_temp and SUPPORT_COLOR_TEMP) self._supported_features |= (effect and SUPPORT_EFFECT) self._supported_features |= (white_value and SUPPORT_WHITE_VALUE) - self._supported_features |= (xy and SUPPORT_COLOR) - self._supported_features |= (hs and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_XY) and SUPPORT_COLOR) + self._supported_features |= (config.get(CONF_HS) and SUPPORT_COLOR) - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) - - last_state = await async_get_last_state(self.hass, self.entity_id) + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): @@ -239,9 +241,9 @@ def state_received(topic, payload, qos): if self._brightness is not None: try: - self._brightness = int(values['brightness'] / - float(self._brightness_scale) * - 255) + self._brightness = int( + values['brightness'] / + float(self._config.get(CONF_BRIGHTNESS_SCALE)) * 255) except KeyError: pass except ValueError: @@ -274,9 +276,11 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topic[CONF_STATE_TOPIC], state_received, - self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._topic[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -291,6 +295,11 @@ def state_received(topic, payload, qos): if last_state.attributes.get(ATTR_WHITE_VALUE): self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + 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 brightness(self): """Return the brightness of this light between 0..255.""" @@ -309,7 +318,7 @@ def effect(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def hs_color(self): @@ -329,7 +338,7 @@ def should_poll(self): @property def name(self): """Return the name of the device if any.""" - return self._name + return self._config.get(CONF_NAME) @property def unique_id(self): @@ -360,11 +369,12 @@ async def async_turn_on(self, **kwargs): message = {'state': 'ON'} - if ATTR_HS_COLOR in kwargs and (self._hs_support - or self._rgb or self._xy): + if ATTR_HS_COLOR in kwargs and ( + self._config.get(CONF_HS) or self._config.get(CONF_RGB) + or self._config.get(CONF_XY)): hs_color = kwargs[ATTR_HS_COLOR] message['color'] = {} - if self._rgb: + if self._config.get(CONF_RGB): # If there's a brightness topic set, we don't want to scale the # RGB values given using the brightness. if self._brightness is not None: @@ -378,11 +388,11 @@ async def async_turn_on(self, **kwargs): message['color']['r'] = rgb[0] message['color']['g'] = rgb[1] message['color']['b'] = rgb[2] - if self._xy: + if self._config.get(CONF_XY): xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) message['color']['x'] = xy_color[0] message['color']['y'] = xy_color[1] - if self._hs_support: + if self._config.get(CONF_HS): message['color']['h'] = hs_color[0] message['color']['s'] = hs_color[1] @@ -402,9 +412,9 @@ async def async_turn_on(self, **kwargs): message['transition'] = int(kwargs[ATTR_TRANSITION]) if ATTR_BRIGHTNESS in kwargs: - message['brightness'] = int(kwargs[ATTR_BRIGHTNESS] / - float(DEFAULT_BRIGHTNESS_SCALE) * - self._brightness_scale) + message['brightness'] = int( + kwargs[ATTR_BRIGHTNESS] / float(DEFAULT_BRIGHTNESS_SCALE) * + self._config.get(CONF_BRIGHTNESS_SCALE)) if self._optimistic: self._brightness = kwargs[ATTR_BRIGHTNESS] @@ -433,7 +443,7 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that the light has changed state. @@ -455,7 +465,7 @@ async def async_turn_off(self, **kwargs): mqtt.async_publish( self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message), - self._qos, self._retain) + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that the light has changed state. diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt/schema_template.py similarity index 79% rename from homeassistant/components/light/mqtt_template.py rename to homeassistant/components/light/mqtt/schema_template.py index 72cfd6b678c257..419472d19276c7 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -11,17 +11,17 @@ from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, - ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, PLATFORM_SCHEMA, + ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, - MqttAvailability) + MqttAvailability, MqttDiscoveryUpdate, subscription) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -44,7 +44,7 @@ CONF_STATE_TEMPLATE = 'state_template' CONF_WHITE_VALUE_TEMPLATE = 'white_value_template' -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template, @@ -66,23 +66,69 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -async def async_setup_platform(hass, config, async_add_entities, - discovery_info=None): +async def async_setup_entity_template(hass, config, async_add_entities, + discovery_hash): """Set up a MQTT Template light.""" - if discovery_info is not None: - config = PLATFORM_SCHEMA(discovery_info) - - async_add_entities([MqttTemplate( - hass, - config.get(CONF_NAME), - config.get(CONF_EFFECT_LIST), - { + async_add_entities([MqttTemplate(config, discovery_hash)]) + + +class MqttTemplate(MqttAvailability, MqttDiscoveryUpdate, Light, + RestoreEntity): + """Representation of a MQTT Template light.""" + + def __init__(self, config, discovery_hash): + """Initialize a MQTT Template light.""" + self._state = False + self._sub_state = None + + self._topics = None + self._templates = None + self._optimistic = False + + # features + self._brightness = None + self._color_temp = None + self._white_value = None + self._hs = None + self._effect = 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) + qos = config.get(CONF_QOS) + + MqttAvailability.__init__(self, availability_topic, qos, + payload_available, payload_not_available) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA_TEMPLATE(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._config = config + + self._topics = { key: config.get(key) for key in ( CONF_STATE_TOPIC, CONF_COMMAND_TOPIC ) - }, - { + } + self._templates = { key: config.get(key) for key in ( CONF_BLUE_TEMPLATE, CONF_BRIGHTNESS_TEMPLATE, @@ -95,36 +141,13 @@ async def async_setup_platform(hass, config, async_add_entities, CONF_STATE_TEMPLATE, CONF_WHITE_VALUE_TEMPLATE, ) - }, - config.get(CONF_OPTIMISTIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - )]) - - -class MqttTemplate(MqttAvailability, Light): - """Representation of a MQTT Template light.""" - - def __init__(self, hass, name, effect_list, topics, templates, optimistic, - qos, retain, availability_topic, payload_available, - payload_not_available): - """Initialize a MQTT Template light.""" - super().__init__(availability_topic, qos, payload_available, - payload_not_available) - self._name = name - self._effect_list = effect_list - self._topics = topics - self._templates = templates - self._optimistic = optimistic or topics[CONF_STATE_TOPIC] is None \ - or templates[CONF_STATE_TEMPLATE] is None - self._qos = qos - self._retain = retain + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic \ + or self._topics[CONF_STATE_TOPIC] is None \ + or self._templates[CONF_STATE_TEMPLATE] is None # features - self._state = False if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None: self._brightness = 255 else: @@ -148,15 +171,13 @@ def __init__(self, hass, name, effect_list, topics, templates, optimistic, self._hs = None self._effect = None + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" for tpl in self._templates.values(): if tpl is not None: - tpl.hass = hass + tpl.hass = self.hass - async def async_added_to_hass(self): - """Subscribe to MQTT events.""" - await super().async_added_to_hass() - - last_state = await async_get_last_state(self.hass, self.entity_id) + last_state = await self.async_get_last_state() @callback def state_received(topic, payload, qos): @@ -216,7 +237,7 @@ def state_received(topic, payload, qos): effect = self._templates[CONF_EFFECT_TEMPLATE].\ async_render_with_possible_json_value(payload) - if effect in self._effect_list: + if effect in self._config.get(CONF_EFFECT_LIST): self._effect = effect else: _LOGGER.warning("Unsupported effect value received") @@ -224,9 +245,11 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: - await mqtt.async_subscribe( - self.hass, self._topics[CONF_STATE_TOPIC], state_received, - self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._topics[CONF_STATE_TOPIC], + 'msg_callback': state_received, + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic and last_state: self._state = last_state.state == STATE_ON @@ -241,6 +264,11 @@ def state_received(topic, payload, qos): if last_state.attributes.get(ATTR_WHITE_VALUE): self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + 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 brightness(self): """Return the brightness of this light between 0..255.""" @@ -272,7 +300,7 @@ def should_poll(self): @property def name(self): """Return the name of the entity.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -287,7 +315,7 @@ def assumed_state(self): @property def effect_list(self): """Return the list of supported effects.""" - return self._effect_list + return self._config.get(CONF_EFFECT_LIST) @property def effect(self): @@ -353,7 +381,7 @@ async def async_turn_on(self, **kwargs): mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_ON_TEMPLATE].async_render(**values), - self._qos, self._retain + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN) ) if self._optimistic: @@ -374,7 +402,7 @@ async def async_turn_off(self, **kwargs): mqtt.async_publish( self.hass, self._topics[CONF_COMMAND_TOPIC], self._templates[CONF_COMMAND_OFF_TEMPLATE].async_render(**values), - self._qos, self._retain + self._config.get(CONF_QOS), self._config.get(CONF_RETAIN) ) if self._optimistic: @@ -388,7 +416,7 @@ def supported_features(self): features = features | SUPPORT_BRIGHTNESS if self._hs is not None: features = features | SUPPORT_COLOR - if self._effect_list is not None: + if self._config.get(CONF_EFFECT_LIST) is not None: features = features | SUPPORT_EFFECT if self._color_temp is not None: features = features | SUPPORT_COLOR_TEMP diff --git a/homeassistant/components/light/niko_home_control.py b/homeassistant/components/light/niko_home_control.py index 3146954ed628e9..6b58ced59897f8 100644 --- a/homeassistant/components/light/niko_home_control.py +++ b/homeassistant/components/light/niko_home_control.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/light.niko_home_control/ """ import logging -import socket import voluptuous as vol @@ -24,11 +23,11 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Niko Home Control light platform.""" import nikohomecontrol - host = config.get(CONF_HOST) + host = config[CONF_HOST] try: hub = nikohomecontrol.Hub({ @@ -37,11 +36,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): 'timeout': 20000, 'events': True }) - except socket.error as err: + except OSError as err: _LOGGER.error("Unable to access %s (%s)", host, err) raise PlatformNotReady - add_devices( + add_entities( [NikoHomeControlLight(light, hub) for light in hub.list_actions()], True) @@ -76,12 +75,10 @@ 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.""" diff --git a/homeassistant/components/light/tellduslive.py b/homeassistant/components/light/tellduslive.py index 07b5458fa4506c..8601fe3cf1f581 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -8,9 +8,10 @@ """ import logging +from homeassistant.components import tellduslive from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components.tellduslive.entry import TelldusLiveEntity _LOGGER = logging.getLogger(__name__) @@ -19,21 +20,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tellstick Net lights.""" if discovery_info is None: return - add_entities(TelldusLiveLight(hass, light) for light in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities(TelldusLiveLight(client, light) for light in discovery_info) class TelldusLiveLight(TelldusLiveEntity, Light): """Representation of a Tellstick Net light.""" - def __init__(self, hass, device_id): + def __init__(self, client, device_id): """Initialize the Tellstick Net light.""" - super().__init__(hass, device_id) + super().__init__(client, device_id) self._last_brightness = self.brightness def changed(self): """Define a property of the device that might have changed.""" self._last_brightness = self.brightness - super().changed() @property def brightness(self): diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index f2e8e120d5348a..9e650562fe8d1d 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -21,7 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import dt -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 15f2d24fa8aac3..25704eea0cc6b0 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -76,6 +76,7 @@ EFFECT_RGB = "RGB" EFFECT_RANDOM_LOOP = "Random Loop" EFFECT_FAST_RANDOM_LOOP = "Fast Random Loop" +EFFECT_LSD = "LSD" EFFECT_SLOWDOWN = "Slowdown" EFFECT_WHATSAPP = "WhatsApp" EFFECT_FACEBOOK = "Facebook" @@ -94,6 +95,7 @@ EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, + EFFECT_LSD, EFFECT_SLOWDOWN, EFFECT_WHATSAPP, EFFECT_FACEBOOK, @@ -413,34 +415,30 @@ def set_effect(self, effect) -> None: from yeelight.transitions import (disco, temp, strobe, pulse, strobe_color, alarm, police, police2, christmas, rgb, - randomloop, slowdown) + randomloop, lsd, slowdown) if effect == EFFECT_STOP: self._bulb.stop_flow() return - if effect == EFFECT_DISCO: - flow = Flow(count=0, transitions=disco()) - if effect == EFFECT_TEMP: - flow = Flow(count=0, transitions=temp()) - if effect == EFFECT_STROBE: - flow = Flow(count=0, transitions=strobe()) - if effect == EFFECT_STROBE_COLOR: - flow = Flow(count=0, transitions=strobe_color()) - if effect == EFFECT_ALARM: - flow = Flow(count=0, transitions=alarm()) - if effect == EFFECT_POLICE: - flow = Flow(count=0, transitions=police()) - if effect == EFFECT_POLICE2: - flow = Flow(count=0, transitions=police2()) - if effect == EFFECT_CHRISTMAS: - flow = Flow(count=0, transitions=christmas()) - if effect == EFFECT_RGB: - flow = Flow(count=0, transitions=rgb()) - if effect == EFFECT_RANDOM_LOOP: - flow = Flow(count=0, transitions=randomloop()) + + effects_map = { + EFFECT_DISCO: disco, + EFFECT_TEMP: temp, + EFFECT_STROBE: strobe, + EFFECT_STROBE_COLOR: strobe_color, + EFFECT_ALARM: alarm, + EFFECT_POLICE: police, + EFFECT_POLICE2: police2, + EFFECT_CHRISTMAS: christmas, + EFFECT_RGB: rgb, + EFFECT_RANDOM_LOOP: randomloop, + EFFECT_LSD: lsd, + EFFECT_SLOWDOWN: slowdown, + } + + if effect in effects_map: + flow = Flow(count=0, transitions=effects_map[effect]()) if effect == EFFECT_FAST_RANDOM_LOOP: flow = Flow(count=0, transitions=randomloop(duration=250)) - if effect == EFFECT_SLOWDOWN: - flow = Flow(count=0, transitions=slowdown()) if effect == EFFECT_WHATSAPP: flow = Flow(count=2, transitions=pulse(37, 211, 102)) if effect == EFFECT_FACEBOOK: diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 56a1e9e5169bb0..83448b39d9eaa0 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,7 +5,13 @@ at https://home-assistant.io/components/light.zha/ """ import logging -from homeassistant.components import light, zha + +from homeassistant.components import light +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -22,30 +28,57 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation lights.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - endpoint = discovery_info['endpoint'] - if hasattr(endpoint, 'light_color'): - caps = await zha.safe_read( - endpoint.light_color, ['color_capabilities']) - discovery_info['color_capabilities'] = caps.get('color_capabilities') - if discovery_info['color_capabilities'] is None: - # ZCL Version 4 devices don't support the color_capabilities - # attribute. In this version XY support is mandatory, but we need - # to probe to determine if the device supports color temperature. - discovery_info['color_capabilities'] = CAPABILITIES_COLOR_XY - result = await zha.safe_read( - endpoint.light_color, ['color_temperature']) - if result.get('color_temperature') is not UNSUPPORTED_ATTRIBUTE: - discovery_info['color_capabilities'] |= CAPABILITIES_COLOR_TEMP - - async_add_entities([Light(**discovery_info)], update_before_add=True) - - -class Light(zha.Entity, light.Light): + """Old way of setting up Zigbee Home Automation lights.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation light from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN) + if lights is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + lights.values()) + del hass.data[DATA_ZHA][light.DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA lights.""" + entities = [] + for discovery_info in discovery_infos: + endpoint = discovery_info['endpoint'] + if hasattr(endpoint, 'light_color'): + caps = await helpers.safe_read( + endpoint.light_color, ['color_capabilities']) + discovery_info['color_capabilities'] = caps.get( + 'color_capabilities') + if discovery_info['color_capabilities'] is None: + # ZCL Version 4 devices don't support the color_capabilities + # attribute. In this version XY support is mandatory, but we + # need to probe to determine if the device supports color + # temperature. + discovery_info['color_capabilities'] = \ + CAPABILITIES_COLOR_XY + result = await helpers.safe_read( + endpoint.light_color, ['color_temperature']) + if (result.get('color_temperature') is not + UNSUPPORTED_ATTRIBUTE): + discovery_info['color_capabilities'] |= \ + CAPABILITIES_COLOR_TEMP + entities.append(Light(**discovery_info)) + + async_add_entities(entities, update_before_add=True) + + +class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" _domain = light.DOMAIN @@ -181,31 +214,37 @@ def supported_features(self): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.on_off, ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) if self._supported_features & light.SUPPORT_BRIGHTNESS: - result = await zha.safe_read(self._endpoint.level, - ['current_level'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.level, + ['current_level'], + allow_cache=False, + only_cache=( + not self._initialized + )) self._brightness = result.get('current_level', self._brightness) if self._supported_features & light.SUPPORT_COLOR_TEMP: - result = await zha.safe_read(self._endpoint.light_color, - ['color_temperature'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.light_color, + ['color_temperature'], + allow_cache=False, + only_cache=( + not self._initialized + )) self._color_temp = result.get('color_temperature', self._color_temp) if self._supported_features & light.SUPPORT_COLOR: - result = await zha.safe_read(self._endpoint.light_color, - ['current_x', 'current_y'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.light_color, + ['current_x', 'current_y'], + allow_cache=False, + only_cache=( + not self._initialized + )) if 'current_x' in result and 'current_y' in result: xy_color = (round(result['current_x']/65535, 3), round(result['current_y']/65535, 3)) diff --git a/homeassistant/components/lightwave.py b/homeassistant/components/lightwave.py new file mode 100644 index 00000000000000..e1aa1664eba49c --- /dev/null +++ b/homeassistant/components/lightwave.py @@ -0,0 +1,49 @@ +""" +Support for device connected via Lightwave WiFi-link hub. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lightwave/ +""" +import voluptuous as vol +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_HOST, CONF_LIGHTS, CONF_NAME, + CONF_SWITCHES) +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['lightwave==0.15'] +LIGHTWAVE_LINK = 'lightwave_link' +DOMAIN = 'lightwave' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema( + cv.has_at_least_one_key(CONF_LIGHTS, CONF_SWITCHES), { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_LIGHTS, default={}): { + cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}), + }, + vol.Optional(CONF_SWITCHES, default={}): { + cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}), + } + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Try to start embedded Lightwave broker.""" + from lightwave.lightwave import LWLink + + host = config[DOMAIN][CONF_HOST] + hass.data[LIGHTWAVE_LINK] = LWLink(host) + + lights = config[DOMAIN][CONF_LIGHTS] + if lights: + hass.async_create_task(async_load_platform( + hass, 'light', DOMAIN, lights, config)) + + switches = config[DOMAIN][CONF_SWITCHES] + if switches: + hass.async_create_task(async_load_platform( + hass, 'switch', DOMAIN, switches, config)) + + return True diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index b62382e6dd10f7..28849c8815906a 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -111,8 +111,7 @@ def __init__(self, name, state_topic, command_topic, qos, retain, async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() @callback def message_received(topic, payload, qos): diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py index 877c8a1ddf6c52..25c7e1aa8ea201 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -8,7 +8,8 @@ from time import sleep from time import time from homeassistant.components.verisure import HUB as hub -from homeassistant.components.verisure import (CONF_LOCKS, CONF_CODE_DIGITS) +from homeassistant.components.verisure import ( + CONF_LOCKS, CONF_DEFAULT_LOCK_CODE, CONF_CODE_DIGITS) from homeassistant.components.lock import LockDevice from homeassistant.const import ( ATTR_CODE, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) @@ -39,6 +40,7 @@ def __init__(self, device_label): self._digits = hub.config.get(CONF_CODE_DIGITS) self._changed_by = None self._change_timestamp = 0 + self._default_lock_code = hub.config.get(CONF_DEFAULT_LOCK_CODE) @property def name(self): @@ -96,13 +98,25 @@ def unlock(self, **kwargs): """Send unlock command.""" if self._state == STATE_UNLOCKED: return - self.set_lock_state(kwargs[ATTR_CODE], STATE_UNLOCKED) + + code = kwargs.get(ATTR_CODE, self._default_lock_code) + if code is None: + _LOGGER.error("Code required but none provided") + return + + self.set_lock_state(code, STATE_UNLOCKED) def lock(self, **kwargs): """Send lock command.""" if self._state == STATE_LOCKED: return - self.set_lock_state(kwargs[ATTR_CODE], STATE_LOCKED) + + code = kwargs.get(ATTR_CODE, self._default_lock_code) + if code is None: + _LOGGER.error("Code required but none provided") + return + + self.set_lock_state(code, STATE_LOCKED) def set_lock_state(self, code, state): """Send set lock state command.""" diff --git a/homeassistant/components/lock/volvooncall.py b/homeassistant/components/lock/volvooncall.py index 58fa83cef30a75..83301aa3d4ecac 100644 --- a/homeassistant/components/lock/volvooncall.py +++ b/homeassistant/components/lock/volvooncall.py @@ -7,17 +7,18 @@ import logging from homeassistant.components.lock import LockDevice -from homeassistant.components.volvooncall import VolvoEntity +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY _LOGGER = logging.getLogger(__name__) -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 Volvo On Call lock.""" if discovery_info is None: return - add_entities([VolvoLock(hass, *discovery_info)]) + async_add_entities([VolvoLock(hass.data[DATA_KEY], *discovery_info)]) class VolvoLock(VolvoEntity, LockDevice): @@ -26,12 +27,12 @@ class VolvoLock(VolvoEntity, LockDevice): @property def is_locked(self): """Return true if lock is locked.""" - return self.vehicle.is_locked + return self.instrument.is_locked - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the car.""" - self.vehicle.lock() + await self.instrument.lock() - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the car.""" - self.vehicle.unlock() + await self.instrument.unlock() diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index b6f434a82ad7dc..78da5733a065ec 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -17,7 +17,8 @@ ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, ATTR_SERVICE, CONF_EXCLUDE, CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, - HTTP_BAD_REQUEST, STATE_NOT_HOME, STATE_OFF, STATE_ON) + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, HTTP_BAD_REQUEST, + STATE_NOT_HOME, STATE_OFF, STATE_ON) from homeassistant.core import ( DOMAIN as HA_DOMAIN, State, callback, split_entity_id) from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME @@ -316,6 +317,28 @@ def humanify(hass, events): 'context_user_id': event.context.user_id } + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': "has been triggered", + 'domain': 'automation', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + + elif event.event_type == EVENT_SCRIPT_STARTED: + yield { + 'when': event.time_fired, + 'name': event.data.get(ATTR_NAME), + 'message': 'started', + 'domain': 'script', + 'entity_id': event.data.get(ATTR_ENTITY_ID), + 'context_id': event.context.id, + 'context_user_id': event.context.user_id + } + def _get_related_entity_ids(session, entity_filter): from homeassistant.components.recorder.models import States diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 5234dbaf29d4a5..e6f122bce19542 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -7,464 +7,137 @@ from functools import wraps import logging import os -from typing import Dict, List, Union import time -import uuid import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.ruamel_yaml as yaml +from homeassistant.util.yaml import load_yaml _LOGGER = logging.getLogger(__name__) DOMAIN = 'lovelace' -LOVELACE_DATA = 'lovelace' +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 +CONF_MODE = 'mode' +MODE_YAML = 'yaml' +MODE_STORAGE = 'storage' -LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' -JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name - -FORMAT_YAML = 'yaml' -FORMAT_JSON = 'json' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_MODE, default=MODE_STORAGE): + vol.All(vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])), + }), +}, extra=vol.ALLOW_EXTRA) -OLD_WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' -WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' -WS_TYPE_MIGRATE_CONFIG = 'lovelace/config/migrate' -WS_TYPE_GET_CARD = 'lovelace/config/card/get' -WS_TYPE_UPDATE_CARD = 'lovelace/config/card/update' -WS_TYPE_ADD_CARD = 'lovelace/config/card/add' -WS_TYPE_MOVE_CARD = 'lovelace/config/card/move' -WS_TYPE_DELETE_CARD = 'lovelace/config/card/delete' +LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' -WS_TYPE_GET_VIEW = 'lovelace/config/view/get' -WS_TYPE_UPDATE_VIEW = 'lovelace/config/view/update' -WS_TYPE_ADD_VIEW = 'lovelace/config/view/add' -WS_TYPE_MOVE_VIEW = 'lovelace/config/view/move' -WS_TYPE_DELETE_VIEW = 'lovelace/config/view/delete' +WS_TYPE_GET_LOVELACE_UI = 'lovelace/config' +WS_TYPE_SAVE_CONFIG = 'lovelace/config/save' SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): - vol.Any(WS_TYPE_GET_LOVELACE_UI, OLD_WS_TYPE_GET_LOVELACE_UI), -}) - -SCHEMA_MIGRATE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MIGRATE_CONFIG, -}) - -SCHEMA_GET_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_GET_CARD, - vol.Required('card_id'): str, - vol.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), + vol.Required('type'): WS_TYPE_GET_LOVELACE_UI, + vol.Optional('force', default=False): bool, }) -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.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), +SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SAVE_CONFIG, + vol.Required('config'): vol.Any(str, dict), }) -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.Optional('position'): int, - vol.Optional('format', default=FORMAT_YAML): - vol.Any(FORMAT_JSON, FORMAT_YAML), -}) -SCHEMA_MOVE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MOVE_CARD, - vol.Required('card_id'): str, - vol.Optional('new_position'): int, - vol.Optional('new_view_id'): str, -}) +class ConfigNotFound(HomeAssistantError): + """When no config available.""" -SCHEMA_DELETE_CARD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_CARD, - vol.Required('card_id'): str, -}) -SCHEMA_GET_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_GET_VIEW, - vol.Required('view_id'): str, - vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) +async def async_setup(hass, config): + """Set up the Lovelace commands.""" + # Pass in default to `get` because defaults not set if loaded as dep + mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) -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.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) + await hass.components.frontend.async_register_built_in_panel( + DOMAIN, config={ + 'mode': mode + }) -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.Optional('position'): int, - vol.Optional('format', default=FORMAT_YAML): vol.Any(FORMAT_JSON, - FORMAT_YAML), -}) + if mode == MODE_YAML: + hass.data[DOMAIN] = LovelaceYAML(hass) + else: + hass.data[DOMAIN] = LovelaceStorage(hass) -SCHEMA_MOVE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_MOVE_VIEW, - vol.Required('view_id'): str, - vol.Required('new_position'): int, -}) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, + SCHEMA_GET_LOVELACE_UI) -SCHEMA_DELETE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_VIEW, - vol.Required('view_id'): str, -}) + hass.components.websocket_api.async_register_command( + WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config, + SCHEMA_SAVE_CONFIG) + return True -class CardNotFoundError(HomeAssistantError): - """Card not found in data.""" - - -class ViewNotFoundError(HomeAssistantError): - """View not found in data.""" - - -class DuplicateIdError(HomeAssistantError): - """Duplicate ID's.""" - - -def load_config(hass) -> JSON_TYPE: - """Load a YAML file.""" - 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: - """Add id to views and cards if not present and check duplicates.""" - config = yaml.load_yaml(fname, True) - updated = False - seen_card_ids = set() - seen_view_ids = set() - index = 0 - for view in config.get('views', []): - view_id = str(view.get('id', '')) - if not view_id: - updated = True - view.insert(0, 'id', index, comment="Automatically created id") - else: - if view_id in seen_view_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurrences in views'.format( - view_id)) - seen_view_ids.add(view_id) - for card in view.get('cards', []): - card_id = str(card.get('id', '')) - if not card_id: - updated = True - card.insert(0, 'id', uuid.uuid4().hex, - comment="Automatically created id") - else: - if card_id in seen_card_ids: - raise DuplicateIdError( - 'ID `{}` has multiple occurrences in cards' - .format(card_id)) - seen_card_ids.add(card_id) - index += 1 - if updated: - yaml.save_yaml(fname, config) - - -def get_card(fname: str, card_id: str, data_format: str = FORMAT_YAML)\ - -> JSON_TYPE: - """Load a specific card config for id.""" - round_trip = data_format == FORMAT_YAML - - config = yaml.load_yaml(fname, round_trip) - - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - if data_format == FORMAT_YAML: - return yaml.object_to_yaml(card) - return card - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def update_card(fname: str, card_id: str, card_config: str, - data_format: str = FORMAT_YAML) -> None: - """Save a specific card config for id.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - if data_format == FORMAT_YAML: - card_config = yaml.yaml_to_object(card_config) - card.clear() - card.update(card_config) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def add_card(fname: str, view_id: str, card_config: str, - position: int = None, data_format: str = FORMAT_YAML) -> None: - """Add a card to a view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - if str(view.get('id', '')) != view_id: - continue - cards = view.get('cards', []) - if data_format == FORMAT_YAML: - card_config = yaml.yaml_to_object(card_config) - if position is None: - cards.append(card_config) - else: - cards.insert(position, card_config) - yaml.save_yaml(fname, config) - return - - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - -def move_card(fname: str, card_id: str, position: int = None) -> None: - """Move a card to a different position.""" - if position is None: - raise HomeAssistantError( - 'Position is required if view is not specified.') - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - cards = view.get('cards') - cards.insert(position, cards.pop(cards.index(card))) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -def move_card_view(fname: str, card_id: str, view_id: str, - position: int = None) -> None: - """Move a card to a different view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - if str(view.get('id', '')) == view_id: - destination = view.get('cards') - for card in view.get('cards'): - if str(card.get('id', '')) != card_id: - continue - origin = view.get('cards') - card_to_move = card - - if 'destination' not in locals(): - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - if 'card_to_move' not in locals(): - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - origin.pop(origin.index(card_to_move)) - - if position is None: - destination.append(card_to_move) - else: - destination.insert(position, card_to_move) - - yaml.save_yaml(fname, config) - - -def delete_card(fname: str, card_id: str) -> None: - """Delete a card from view.""" - config = yaml.load_yaml(fname, True) - for view in config.get('views', []): - for card in view.get('cards', []): - if str(card.get('id', '')) != card_id: - continue - cards = view.get('cards') - cards.pop(cards.index(card)) - yaml.save_yaml(fname, config) - return - - raise CardNotFoundError( - "Card with ID: {} was not found in {}.".format(card_id, fname)) - - -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: - found = view - break - if found is None: - 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: - 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, - position: int = None, data_format: str = FORMAT_YAML) -> None: - """Add a view.""" - config = yaml.load_yaml(fname, True) - views = config.get('views', []) - if data_format == FORMAT_YAML: - view_config = yaml.yaml_to_object(view_config) - if position is None: - views.append(view_config) - else: - views.insert(position, view_config) - yaml.save_yaml(fname, config) - - -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: - found = view - break - if found is None: - 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: - found = view - break - if found is None: - raise ViewNotFoundError( - "View with ID: {} was not found in {}.".format(view_id, fname)) - - views.pop(views.index(found)) - yaml.save_yaml(fname, config) +class LovelaceStorage: + """Class to handle Storage based Lovelace config.""" -async def async_setup(hass, config): - """Set up the Lovelace commands.""" - # Backwards compat. Added in 0.80. Remove after 0.85 - hass.components.websocket_api.async_register_command( - OLD_WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, - SCHEMA_GET_LOVELACE_UI) + def __init__(self, hass): + """Initialize Lovelace config based on storage helper.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None - hass.components.websocket_api.async_register_command( - WS_TYPE_MIGRATE_CONFIG, websocket_lovelace_migrate_config, - SCHEMA_MIGRATE_CONFIG) + async def async_load(self, force): + """Load config.""" + if self._data is None: + data = await self._store.async_load() + self._data = data if data else {'config': None} - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, - SCHEMA_GET_LOVELACE_UI) + config = self._data['config'] - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_CARD, websocket_lovelace_get_card, SCHEMA_GET_CARD) + if config is None: + raise ConfigNotFound - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_CARD, websocket_lovelace_update_card, - SCHEMA_UPDATE_CARD) + return config - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_CARD, websocket_lovelace_add_card, SCHEMA_ADD_CARD) + async def async_save(self, config): + """Save config.""" + self._data['config'] = config + await self._store.async_save(self._data) - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_CARD, websocket_lovelace_move_card, SCHEMA_MOVE_CARD) - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_CARD, websocket_lovelace_delete_card, - SCHEMA_DELETE_CARD) +class LovelaceYAML: + """Class to handle YAML-based Lovelace config.""" - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_VIEW, websocket_lovelace_get_view, SCHEMA_GET_VIEW) + def __init__(self, hass): + """Initialize the YAML config.""" + self.hass = hass + self._cache = None - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_VIEW, websocket_lovelace_update_view, - SCHEMA_UPDATE_VIEW) + async def async_load(self, force): + """Load config.""" + return await self.hass.async_add_executor_job(self._load_config, force) - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_VIEW, websocket_lovelace_add_view, SCHEMA_ADD_VIEW) + def _load_config(self, force): + """Load the actual config.""" + fname = self.hass.config.path(LOVELACE_CONFIG_FILE) + # Check for a cached version of the config + if not force and self._cache is not None: + config, last_update = self._cache + modtime = os.path.getmtime(fname) + if config and last_update > modtime: + return config - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_VIEW, websocket_lovelace_move_view, SCHEMA_MOVE_VIEW) + try: + config = load_yaml(fname) + except FileNotFoundError: + raise ConfigNotFound from None - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_VIEW, websocket_lovelace_delete_view, - SCHEMA_DELETE_VIEW) + self._cache = (config, time.time()) + return config - return True + async def async_save(self, config): + """Save config.""" + raise HomeAssistantError('Not supported') def handle_yaml_errors(func): @@ -477,19 +150,8 @@ async def send_with_error_handling(hass, connection, msg): message = websocket_api.result_message( msg['id'], result ) - except FileNotFoundError: - error = ('file_not_found', - 'Could not find ui-lovelace.yaml in your config dir.') - except yaml.UnsupportedYamlError as err: - 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: - error = 'view_not_found', str(err) + except ConfigNotFound: + error = 'config_not_found', 'No config found.' except HomeAssistantError as err: error = 'error', str(err) @@ -505,107 +167,11 @@ async def send_with_error_handling(hass, connection, msg): @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) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_migrate_config(hass, connection, msg): - """Migrate Lovelace UI configuration.""" - return await hass.async_add_executor_job( - migrate_config, hass.config.path(LOVELACE_CONFIG_FILE)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_get_card(hass, connection, msg): - """Send Lovelace card config over WebSocket configuration.""" - return await hass.async_add_executor_job( - get_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id'], - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_update_card(hass, connection, msg): - """Receive Lovelace card configuration over WebSocket and save.""" - return await hass.async_add_executor_job( - update_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg['card_config'], msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_add_card(hass, connection, msg): - """Add new card to view over WebSocket and save.""" - return await hass.async_add_executor_job( - add_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['card_config'], msg.get('position'), - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_move_card(hass, connection, msg): - """Move card to different position over WebSocket and save.""" - if 'new_view_id' in msg: - return await hass.async_add_executor_job( - move_card_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg['new_view_id'], msg.get('new_position')) - - return await hass.async_add_executor_job( - move_card, hass.config.path(LOVELACE_CONFIG_FILE), - msg['card_id'], msg.get('new_position')) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_delete_card(hass, connection, msg): - """Delete card from Lovelace over WebSocket and save.""" - return await hass.async_add_executor_job( - delete_card, hass.config.path(LOVELACE_CONFIG_FILE), msg['card_id']) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_get_view(hass, connection, msg): - """Send Lovelace view config over WebSocket config.""" - return await hass.async_add_executor_job( - get_view, hass.config.path(LOVELACE_CONFIG_FILE), msg['view_id'], - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_update_view(hass, connection, msg): - """Receive Lovelace card config over WebSocket and save.""" - return await hass.async_add_executor_job( - update_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['view_config'], msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_add_view(hass, connection, msg): - """Add new view over WebSocket and save.""" - return await hass.async_add_executor_job( - add_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_config'], msg.get('position'), - msg.get('format', FORMAT_YAML)) - - -@websocket_api.async_response -@handle_yaml_errors -async def websocket_lovelace_move_view(hass, connection, msg): - """Move view to different position over WebSocket and save.""" - return await hass.async_add_executor_job( - move_view, hass.config.path(LOVELACE_CONFIG_FILE), - msg['view_id'], msg['new_position']) + return await hass.data[DOMAIN].async_load(msg['force']) @websocket_api.async_response @handle_yaml_errors -async def websocket_lovelace_delete_view(hass, connection, msg): - """Delete card from Lovelace over WebSocket and save.""" - return await hass.async_add_executor_job( - delete_view, hass.config.path(LOVELACE_CONFIG_FILE), msg['view_id']) +async def websocket_lovelace_save_config(hass, connection, msg): + """Save Lovelace UI configuration.""" + await hass.data[DOMAIN].async_save(msg['config']) diff --git a/homeassistant/components/luftdaten/.translations/hu.json b/homeassistant/components/luftdaten/.translations/hu.json new file mode 100644 index 00000000000000..48914a944654c8 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/hu.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Luftdaten be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "Luftdaten" + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/pt.json b/homeassistant/components/luftdaten/.translations/pt.json new file mode 100644 index 00000000000000..6a242c441af4e9 --- /dev/null +++ b/homeassistant/components/luftdaten/.translations/pt.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "communication_error": "N\u00e3o \u00e9 poss\u00edvel comunicar com a API da Luftdaten", + "invalid_sensor": "Sensor n\u00e3o dispon\u00edvel ou inv\u00e1lido", + "sensor_exists": "Sensor j\u00e1 registado" + }, + "step": { + "user": { + "data": { + "show_on_map": "Mostrar no mapa" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json index 506a5c05485609..d37aa3567d1977 100644 --- a/homeassistant/components/luftdaten/.translations/ru.json +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -11,7 +11,7 @@ "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" } }, "title": "Luftdaten" diff --git a/homeassistant/components/lupusec.py b/homeassistant/components/lupusec.py index 162b49ef9b2368..94cb3abc4a2796 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['lupupy==0.0.10'] +REQUIREMENTS = ['lupupy==0.0.17'] DOMAIN = 'lupusec' diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json index f31c4838a4dd4e..fcb087e68852f3 100644 --- a/homeassistant/components/mailgun/.translations/ca.json +++ b/homeassistant/components/mailgun/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/mailgun/.translations/ko.json b/homeassistant/components/mailgun/.translations/ko.json index 0dd8cbdb47d263..95897a25f1535e 100644 --- a/homeassistant/components/mailgun/.translations/ko.json +++ b/homeassistant/components/mailgun/.translations/ko.json @@ -5,7 +5,7 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { diff --git a/homeassistant/components/mailgun/.translations/ru.json b/homeassistant/components/mailgun/.translations/ru.json index 62007a95809269..b1828ee28ef39e 100644 --- a/homeassistant/components/mailgun/.translations/ru.json +++ b/homeassistant/components/mailgun/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "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 Mailgun?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Mailgun Webhook" + "title": "Mailgun Webhook" } }, "title": "Mailgun" diff --git a/homeassistant/components/mailgun/.translations/sl.json b/homeassistant/components/mailgun/.translations/sl.json index 12dad4d8c7ec31..4eb12d7343ce90 100644 --- a/homeassistant/components/mailgun/.translations/sl.json +++ b/homeassistant/components/mailgun/.translations/sl.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Potrebna je samo ena instanca." }, "create_entry": { - "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." + "default": "Za po\u0161iljanje dogodkov Home Assistentu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/json\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov." }, "step": { "user": { diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index de60f7eee933e9..296c6c8d75dd17 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.11.07'] +REQUIREMENTS = ['youtube_dl==2018.11.23'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index c2a736f531e0b1..8a88e3bd74e42d 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ +import homeassistant.util.dt as dt_util from homeassistant.components.media_player import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, @@ -12,7 +13,6 @@ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING -import homeassistant.util.dt as dt_util def setup_platform(hass, config, add_entities, discovery_info=None): @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): YOUTUBE_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \ - SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE + SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index bf934311303675..c565a161b101e2 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -21,7 +21,7 @@ STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.7.6'] +REQUIREMENTS = ['denonavr==0.7.7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 7a1e240d82e1db..d8c67e372b2fba 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -162,8 +162,8 @@ def update(self): 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 + self._last_update = dt_util.utcnow() if not self._paused \ + or self._last_update is None else self._last_update else: self._available = False except requests.RequestException as ex: diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 0c1984b3bce665..80be58c04e104e 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -125,7 +125,7 @@ class FireTVDevice(MediaPlayerDevice): def __init__(self, ftv, name, get_source, get_sources): """Initialize the FireTV device.""" from adb.adb_protocol import ( - InvalidCommandError, InvalidResponseError, InvalidChecksumError) + InvalidChecksumError, InvalidCommandError, InvalidResponseError) self.firetv = ftv @@ -137,9 +137,9 @@ def __init__(self, ftv, name, get_source, get_sources): self.adb_lock = threading.Lock() # ADB exceptions to catch - self.exceptions = (TypeError, ValueError, AttributeError, - InvalidCommandError, InvalidResponseError, - InvalidChecksumError) + self.exceptions = (AttributeError, BrokenPipeError, TypeError, + ValueError, InvalidChecksumError, + InvalidCommandError, InvalidResponseError) self._state = None self._available = self.firetv.available diff --git a/homeassistant/components/media_player/hdmi_cec.py b/homeassistant/components/media_player/hdmi_cec.py index cb4afadd058f1f..d69d8a74ce6fb2 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -13,7 +13,6 @@ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) -from homeassistant.core import HomeAssistant DEPENDENCIES = ['hdmi_cec'] @@ -26,20 +25,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Find and return HDMI devices as +switches.""" if ATTR_NEW in discovery_info: _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) - add_entities(CecPlayerDevice(hass, hass.data.get(device), - hass.data.get(device).logical_address) for - device in discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecPlayerDevice( + hdmi_device, hdmi_device.logical_address, + )) + add_entities(entities, True) class CecPlayerDevice(CecDevice, MediaPlayerDevice): """Representation of a HDMI device as a Media player.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, hass, device, logical) + CecDevice.__init__(self, device, logical) self.entity_id = "%s.%s_%s" % ( DOMAIN, 'hdmi', hex(self._logical_address)[2:]) - self.update() def send_keypress(self, key): """Send keypress to CEC adapter.""" @@ -137,25 +139,24 @@ def state(self) -> str: """Cache state of device.""" return self._state - def _update(self, device=None): + def update(self): """Update device status.""" - if device: - from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ - POWER_OFF, POWER_ON - if device.power_status == POWER_OFF: - self._state = STATE_OFF - elif not self.support_pause: - if device.power_status == POWER_ON: - self._state = STATE_ON - elif device.status == STATUS_PLAY: - self._state = STATE_PLAYING - elif device.status == STATUS_STOP: - self._state = STATE_IDLE - elif device.status == STATUS_STILL: - self._state = STATE_PAUSED - else: - _LOGGER.warning("Unknown state: %s", device.status) - self.schedule_update_ha_state() + device = self._device + from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \ + POWER_OFF, POWER_ON + if device.power_status in [POWER_OFF, 3]: + self._state = STATE_OFF + elif not self.support_pause: + if device.power_status in [POWER_ON, 4]: + self._state = STATE_ON + elif device.status == STATUS_PLAY: + self._state = STATE_PLAYING + elif device.status == STATUS_STOP: + self._state = STATE_IDLE + elif device.status == STATUS_STILL: + self._state = STATE_PAUSED + else: + _LOGGER.warning("Unknown state: %s", device.status) @property def supported_features(self): diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 0b4069ed664907..b70c1ffbf28585 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -150,7 +150,7 @@ def update_devices(): _LOGGER.exception("Error listing plex devices") return except requests.exceptions.RequestException as ex: - _LOGGER.error( + _LOGGER.warning( "Could not connect to plex server at http://%s (%s)", host, ex) return @@ -218,7 +218,7 @@ def update_sessions(): _LOGGER.exception("Error listing plex sessions") return except requests.exceptions.RequestException as ex: - _LOGGER.error( + _LOGGER.warning( "Could not connect to plex server at http://%s (%s)", host, ex) return diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 9564a8d3df0b28..e3f426cc5c6e17 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -20,7 +20,7 @@ STATE_UNKNOWN) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pyvizio==0.0.3'] +REQUIREMENTS = ['pyvizio==0.0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/.translations/cs.json b/homeassistant/components/mqtt/.translations/cs.json index e76577a5dc8fbe..dbda456587eb33 100644 --- a/homeassistant/components/mqtt/.translations/cs.json +++ b/homeassistant/components/mqtt/.translations/cs.json @@ -15,6 +15,7 @@ "port": "Port", "username": "U\u017eivatelsk\u00e9 jm\u00e9no" }, + "description": "Zadejte informace proo p\u0159ipojen\u00ed zprost\u0159edkovatele protokolu MQTT.", "title": "MQTT" }, "hassio_confirm": { diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 7e35c219c45c77..9757716b1bffae 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -4,7 +4,7 @@ "single_instance_allowed": "\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." }, "error": { - "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443." + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443" }, "step": { "broker": { diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 66b105326649ed..6093be7d0915fb 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -827,18 +827,20 @@ def __init__(self, availability_topic: Optional[str], qos: Optional[int], payload_available: Optional[str], payload_not_available: Optional[str]) -> None: """Initialize the availability mixin.""" + self._availability_sub_state = None + self._available = False # type: bool + self._availability_topic = availability_topic self._availability_qos = qos - 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 super().async_added_to_hass() await self._availability_subscribe_topics() async def availability_discovery_update(self, config: dict): @@ -849,6 +851,7 @@ async def availability_discovery_update(self, config: dict): def _availability_setup_from_config(self, config): """(Re)Setup.""" self._availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + self._availability_qos = config.get(CONF_QOS) self._payload_available = config.get(CONF_PAYLOAD_AVAILABLE) self._payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) @@ -883,7 +886,7 @@ async def async_will_remove_from_hass(self): @property def available(self) -> bool: """Return if the device is available.""" - return self._available + return self._availability_topic is None or self._available class MqttDiscoveryUpdate(Entity): @@ -897,6 +900,8 @@ def __init__(self, discovery_hash, discovery_update=None) -> None: async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" + await super().async_added_to_hass() + from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.mqtt.discovery import ( ALREADY_DISCOVERED, MQTT_DISCOVERY_UPDATED) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index bf83b1739724c5..8d5f28278d9e04 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -27,32 +27,26 @@ 'light', 'sensor', 'switch', 'lock', 'climate', 'alarm_control_panel'] -ALLOWED_PLATFORMS = { - 'binary_sensor': ['mqtt'], - 'camera': ['mqtt'], - 'cover': ['mqtt'], - 'fan': ['mqtt'], - 'light': ['mqtt', 'mqtt_json', 'mqtt_template'], - 'lock': ['mqtt'], - 'sensor': ['mqtt'], - 'switch': ['mqtt'], - 'climate': ['mqtt'], - 'alarm_control_panel': ['mqtt'], -} +CONFIG_ENTRY_COMPONENTS = [ + 'binary_sensor', + 'camera', + 'cover', + 'light', + 'lock', + 'sensor', + 'switch', + 'climate', + 'alarm_control_panel', + 'fan', +] -CONFIG_ENTRY_PLATFORMS = { - 'binary_sensor': ['mqtt'], - 'camera': ['mqtt'], - 'cover': ['mqtt'], - 'light': ['mqtt'], - 'lock': ['mqtt'], - 'sensor': ['mqtt'], - 'switch': ['mqtt'], - 'climate': ['mqtt'], - 'alarm_control_panel': ['mqtt'], - 'fan': ['mqtt'], +DEPRECATED_PLATFORM_TO_SCHEMA = { + 'mqtt': 'basic', + 'mqtt_json': 'json', + 'mqtt_template': 'template', } + ALREADY_DISCOVERED = 'mqtt_discovered_components' DATA_CONFIG_ENTRY_LOCK = 'mqtt_config_entry_lock' CONFIG_ENTRY_IS_SETUP = 'mqtt_config_entry_is_setup' @@ -213,12 +207,15 @@ async def async_device_message_received(topic, payload, qos): 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_PLATFORM in payload: + platform = payload[CONF_PLATFORM] + if platform in DEPRECATED_PLATFORM_TO_SCHEMA: + schema = DEPRECATED_PLATFORM_TO_SCHEMA[platform] + payload['schema'] = schema + _LOGGER.warning('"platform": "%s" is deprecated, ' + 'replace with "schema":"%s"', + platform, schema) + payload[CONF_PLATFORM] = 'mqtt' if CONF_STATE_TOPIC not in payload: payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format( @@ -241,12 +238,12 @@ async def async_device_message_received(topic, payload, qos): _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, []): + if component not in CONFIG_ENTRY_COMPONENTS: await async_load_platform( - hass, component, platform, payload, hass_config) + hass, component, 'mqtt', payload, hass_config) return - config_entries_key = '{}.{}'.format(component, platform) + config_entries_key = '{}.{}'.format(component, 'mqtt') async with hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: await hass.config_entries.async_forward_entry_setup( @@ -254,7 +251,7 @@ async def async_device_message_received(topic, payload, qos): hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) async_dispatcher_send(hass, MQTT_DISCOVERY_NEW.format( - component, platform), payload) + component, 'mqtt'), payload) hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 8be8d311d9b532..26101f32f89896 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -5,45 +5,90 @@ https://home-assistant.io/components/mqtt/ """ import logging +from typing import Any, Callable, Dict, Optional + +import attr from homeassistant.components import mqtt -from homeassistant.components.mqtt import DEFAULT_QOS -from homeassistant.loader import bind_hass +from homeassistant.components.mqtt import DEFAULT_QOS, MessageCallbackType from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) +@attr.s(slots=True) +class EntitySubscription: + """Class to hold data about an active entity topic subscription.""" + + topic = attr.ib(type=str) + message_callback = attr.ib(type=MessageCallbackType) + unsubscribe_callback = attr.ib(type=Optional[Callable[[], None]]) + qos = attr.ib(type=int, default=0) + encoding = attr.ib(type=str, default='utf-8') + + async def resubscribe_if_necessary(self, hass, other): + """Re-subscribe to the new topic if necessary.""" + if not self._should_resubscribe(other): + return + + if other is not None and other.unsubscribe_callback is not None: + other.unsubscribe_callback() + + if self.topic is None: + # We were asked to remove the subscription or not to create it + return + + self.unsubscribe_callback = await mqtt.async_subscribe( + hass, self.topic, self.message_callback, + self.qos, self.encoding + ) + + def _should_resubscribe(self, other): + """Check if we should re-subscribe to the topic using the old state.""" + if other is None: + return True + + return (self.topic, self.qos, self.encoding) != \ + (other.topic, other.qos, other.encoding) + + @bind_hass -async def async_subscribe_topics(hass: HomeAssistantType, sub_state: dict, - topics: dict): +async def async_subscribe_topics(hass: HomeAssistantType, + new_state: Optional[Dict[str, + EntitySubscription]], + topics: Dict[str, Any]): """(Re)Subscribe to a set of MQTT topics. - State is kept in sub_state. + State is kept in sub_state and a dictionary mapping from the subscription + key to the subscription state. + + Please note that the sub state must not be shared between multiple + sets of topics. Every call to async_subscribe_topics must always + contain _all_ the topics the subscription state should manage. """ - 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() + current_subscriptions = new_state if new_state is not None else {} + new_state = {} + for key, value in topics.items(): + # Extract the new requested subscription + requested = EntitySubscription( + topic=value.get('topic', None), + message_callback=value.get('msg_callback', None), + unsubscribe_callback=None, + qos=value.get('qos', DEFAULT_QOS), + encoding=value.get('encoding', 'utf-8'), + ) + # Get the current subscription state + current = current_subscriptions.pop(key, None) + await requested.resubscribe_if_necessary(hass, current) + new_state[key] = requested - return sub_state + # Go through all remaining subscriptions and unsubscribe them + for remaining in current_subscriptions.values(): + if remaining.unsubscribe_callback is not None: + remaining.unsubscribe_callback() + + return new_state @bind_hass diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 0e01310115fde0..2cde7825734f72 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -13,7 +13,7 @@ from homeassistant.components.mqtt import ( valid_publish_topic, valid_subscribe_topic) from homeassistant.const import ( - ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_SERVICE_EXECUTED, + ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL) from homeassistant.core import EventOrigin, State import homeassistant.helpers.config_validation as cv @@ -69,16 +69,6 @@ def _event_publisher(event): ): return - # Filter out all the "event service executed" events because they - # are only used internally by core as callbacks for blocking - # during the interval while a service is being executed. - # They will serve no purpose to the external system, - # and thus are unnecessary traffic. - # And at any rate it would cause an infinite loop to publish them - # because publishing to an MQTT topic itself triggers one. - if event.event_type == EVENT_SERVICE_EXECUTED: - return - event_info = {'event_type': event.event_type, 'event_data': event.data} msg = json.dumps(event_info, cls=JSONEncoder) mqtt.async_publish(pub_topic, msg) diff --git a/homeassistant/components/mychevy.py b/homeassistant/components/mychevy.py index baac86f4bf1976..685bbb90cbb98e 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ["mychevy==0.4.0"] +REQUIREMENTS = ["mychevy==1.0.1"] DOMAIN = 'mychevy' UPDATE_TOPIC = DOMAIN diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 6c5fac074ba418..c6ab06d6884563 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -56,25 +56,68 @@ } ERRORS = { + 'ui_error_battery_battundervoltlithiumsafety': 'Replace battery', + 'ui_error_battery_critical': 'Replace battery', + 'ui_error_battery_invalidsensor': 'Replace battery', + 'ui_error_battery_lithiumadapterfailure': 'Replace battery', + 'ui_error_battery_mismatch': 'Replace battery', + 'ui_error_battery_nothermistor': 'Replace battery', + 'ui_error_battery_overtemp': 'Replace battery', + 'ui_error_battery_undercurrent': 'Replace battery', + 'ui_error_battery_undertemp': 'Replace battery', + 'ui_error_battery_undervolt': 'Replace battery', + 'ui_error_battery_unplugged': 'Replace battery', 'ui_error_brush_stuck': 'Brush stuck', 'ui_error_brush_overloaded': 'Brush overloaded', 'ui_error_bumper_stuck': 'Bumper stuck', + 'ui_error_check_battery_switch': 'Check battery', + 'ui_error_corrupt_scb': 'Call customer service corrupt board', + 'ui_error_deck_debris': 'Deck debris', 'ui_error_dust_bin_missing': 'Dust bin missing', 'ui_error_dust_bin_full': 'Dust bin full', 'ui_error_dust_bin_emptied': 'Dust bin emptied', + 'ui_error_hardware_failure': 'Hardware failure', + 'ui_error_ldrop_stuck': 'Clear my path', + 'ui_error_lwheel_stuck': 'Clear my path', + 'ui_error_navigation_backdrop_frontbump': 'Clear my path', 'ui_error_navigation_backdrop_leftbump': 'Clear my path', + 'ui_error_navigation_backdrop_wheelextended': 'Clear my path', 'ui_error_navigation_noprogress': 'Clear my path', 'ui_error_navigation_origin_unclean': 'Clear my path', 'ui_error_navigation_pathproblems_returninghome': 'Cannot return to base', 'ui_error_navigation_falling': 'Clear my path', + 'ui_error_navigation_noexitstogo': 'Clear my path', + 'ui_error_navigation_nomotioncommands': 'Clear my path', + 'ui_error_navigation_rightdrop_leftbump': 'Clear my path', + 'ui_error_navigation_undockingfailed': 'Clear my path', 'ui_error_picked_up': 'Picked up', + 'ui_error_rdrop_stuck': 'Clear my path', + 'ui_error_rwheel_stuck': 'Clear my path', 'ui_error_stuck': 'Stuck!', + 'ui_error_unable_to_see': 'Clean vacuum sensors', + 'ui_error_vacuum_slip': 'Clear my path', + 'ui_error_vacuum_stuck': 'Clear my path', + 'ui_error_warning': 'Error check app', + 'batt_base_connect_fail': 'Battery failed to connect to base', + 'batt_base_no_power': 'Battery base has no power', + 'batt_low': 'Battery low', + 'batt_on_base': 'Battery on base', + 'clean_tilt_on_start': 'Clean the tilt on start', 'dustbin_full': 'Dust bin full', 'dustbin_missing': 'Dust bin missing', + 'gen_picked_up': 'Picked up', + 'hw_fail': 'Hardware failure', + 'hw_tof_sensor_sensor': 'Hardware sensor disconnected', + 'lds_bad_packets': 'Bad packets', + 'lds_deck_debris': 'Debris on deck', + 'lds_disconnected': 'Disconnected', + 'lds_jammed': 'Jammed', 'maint_brush_stuck': 'Brush stuck', 'maint_brush_overload': 'Brush overloaded', 'maint_bumper_stuck': 'Bumper stuck', + 'maint_customer_support_qa': 'Contact customer support', 'maint_vacuum_stuck': 'Vacuum is stuck', + 'maint_vacuum_slip': 'Vacuum is stuck', 'maint_left_drop_stuck': 'Vacuum is stuck', 'maint_left_wheel_stuck': 'Vacuum is stuck', 'maint_right_drop_stuck': 'Vacuum is stuck', @@ -82,7 +125,15 @@ 'not_on_charge_base': 'Not on the charge base', 'nav_robot_falling': 'Clear my path', 'nav_no_path': 'Clear my path', - 'nav_path_problem': 'Clear my path' + 'nav_path_problem': 'Clear my path', + 'nav_backdrop_frontbump': 'Clear my path', + 'nav_backdrop_leftbump': 'Clear my path', + 'nav_backdrop_wheelextended': 'Clear my path', + 'nav_mag_sensor': 'Clear my path', + 'nav_no_exit': 'Clear my path', + 'nav_no_movement': 'Clear my path', + 'nav_rightdrop_leftbump': 'Clear my path', + 'nav_undocking_failed': 'Clear my path' } ALERTS = { diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml new file mode 100644 index 00000000000000..e10e626464378e --- /dev/null +++ b/homeassistant/components/nest/services.yaml @@ -0,0 +1,37 @@ +# Describes the format for available Nest services + +set_away_mode: + description: Set the away mode for a Nest structure. + fields: + away_mode: + description: New mode to set. Valid modes are "away" or "home". + example: "away" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" + +set_eta: + description: Set or update the estimated time of arrival window for a Nest structure. + fields: + eta: + description: Estimated time of arrival from now. + example: "00:10:30" + eta_window: + description: Estimated time of arrival window. Default is 1 minute. + example: "00:05" + trip_id: + description: Unique ID for the trip. Default is auto-generated using a timestamp. + example: "Leave Work" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" + +cancel_eta: + description: Cancel an existing estimated time of arrival window for a Nest structure. + fields: + trip_id: + description: Unique ID for the trip. + example: "Leave Work" + structure: + description: Name(s) of structure(s) to change. Defaults to all structures if not specified. + example: "Apartment" diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index d8924c6c30124e..50bd290797d692 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyatmo==1.2'] +REQUIREMENTS = ['pyatmo==1.4'] _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ def setup(hass, config): config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], 'read_station read_camera access_camera ' 'read_thermostat write_thermostat ' - 'read_presence access_presence') + 'read_presence access_presence read_homecoach') except HTTPError: _LOGGER.error("Unable to connect to Netatmo API") return False diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index fa93cc4ba4ddb6..771606b935fe04 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -242,7 +242,7 @@ def decode_jwt(self, token): # 2b. If decode is unsuccessful, return a 401. target_check = jwt.decode(token, verify=False) - if target_check[ATTR_TARGET] in self.registrations: + if target_check.get(ATTR_TARGET) in self.registrations: possible_target = self.registrations[target_check[ATTR_TARGET]] key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH] try: diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py deleted file mode 100644 index e792045ec8010d..00000000000000 --- a/homeassistant/components/notify/instapush.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Instapush notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.instapush/ -""" -import json -import logging - -from aiohttp.hdrs import CONTENT_TYPE -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, PLATFORM_SCHEMA, ATTR_TITLE_DEFAULT, BaseNotificationService) -from homeassistant.const import CONF_API_KEY, CONTENT_TYPE_JSON -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://api.instapush.im/v1/' - -CONF_APP_SECRET = 'app_secret' -CONF_EVENT = 'event' -CONF_TRACKER = 'tracker' - -DEFAULT_TIMEOUT = 10 - -HTTP_HEADER_APPID = 'x-instapush-appid' -HTTP_HEADER_APPSECRET = 'x-instapush-appsecret' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_APP_SECRET): cv.string, - vol.Required(CONF_EVENT): cv.string, - vol.Required(CONF_TRACKER): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the Instapush notification service.""" - headers = { - HTTP_HEADER_APPID: config[CONF_API_KEY], - HTTP_HEADER_APPSECRET: config[CONF_APP_SECRET], - } - - try: - response = requests.get( - '{}{}'.format(_RESOURCE, 'events/list'), headers=headers, - timeout=DEFAULT_TIMEOUT).json() - except ValueError: - _LOGGER.error("Unexpected answer from Instapush API") - return None - - if 'error' in response: - _LOGGER.error(response['msg']) - return None - - if not [app for app in response if app['title'] == config[CONF_EVENT]]: - _LOGGER.error("No app match your given value") - return None - - return InstapushNotificationService( - config.get(CONF_API_KEY), config.get(CONF_APP_SECRET), - config.get(CONF_EVENT), config.get(CONF_TRACKER)) - - -class InstapushNotificationService(BaseNotificationService): - """Implementation of the notification service for Instapush.""" - - def __init__(self, api_key, app_secret, event, tracker): - """Initialize the service.""" - self._api_key = api_key - self._app_secret = app_secret - self._event = event - self._tracker = tracker - self._headers = { - HTTP_HEADER_APPID: self._api_key, - HTTP_HEADER_APPSECRET: self._app_secret, - CONTENT_TYPE: CONTENT_TYPE_JSON, - } - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - data = { - 'event': self._event, - 'trackers': {self._tracker: '{} : {}'.format(title, message)} - } - - response = requests.post( - '{}{}'.format(_RESOURCE, 'post'), data=json.dumps(data), - headers=self._headers, timeout=DEFAULT_TIMEOUT) - - if response.json()['status'] == 401: - _LOGGER.error(response.json()['msg'], - "Please check your Instapush settings") diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index d576cdcc95e780..599633ff5ff2fa 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ BaseNotificationService) from homeassistant.const import (CONF_API_KEY, CONF_USERNAME, CONF_ICON) -REQUIREMENTS = ['slacker==0.9.65'] +REQUIREMENTS = ['slacker==0.11.0'] _LOGGER = logging.getLogger(__name__) @@ -39,14 +39,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_CHANNEL): cv.string, - vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_ICON): cv.string, + vol.Optional(CONF_USERNAME): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" import slacker + channel = config.get(CONF_CHANNEL) api_key = config.get(CONF_API_KEY) username = config.get(CONF_USERNAME) @@ -115,15 +116,15 @@ def send_message(self, message="", **kwargs): 'content': None, 'filetype': None, 'filename': filename, - # if optional title is none use the filename + # If optional title is none use the filename 'title': title if title else filename, 'initial_comment': message, 'channels': target } # Post to slack - self.slack.files.post('files.upload', - data=data, - files={'file': file_as_bytes}) + self.slack.files.post( + 'files.upload', data=data, + files={'file': file_as_bytes}) else: self.slack.chat.post_message( target, message, as_user=self._as_user, @@ -154,13 +155,13 @@ def load_file(self, url=None, local_path=None, username=None, elif local_path: # Check whether path is whitelisted in configuration.yaml if self.is_allowed_path(local_path): - return open(local_path, "rb") - _LOGGER.warning("'%s' is not secure to load data from!", - local_path) + return open(local_path, 'rb') + _LOGGER.warning( + "'%s' is not secure to load data from!", local_path) else: _LOGGER.warning("Neither URL nor local path found in params!") except OSError as error: - _LOGGER.error("Can't load from url or local path: %s", error) + _LOGGER.error("Can't load from URL or local path: %s", error) return None diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 376575e34408a4..25aca9f8afaaf7 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -14,10 +14,6 @@ @callback def async_is_onboarded(hass): """Return if Home Assistant has been onboarded.""" - # Temporarily: if auth not active, always set onboarded=True - if not hass.auth.active: - return True - return hass.data.get(DOMAIN, True) diff --git a/homeassistant/components/openuv/.translations/cs.json b/homeassistant/components/openuv/.translations/cs.json new file mode 100644 index 00000000000000..9f6ad4f8d47f5a --- /dev/null +++ b/homeassistant/components/openuv/.translations/cs.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "identifier_exists": "Sou\u0159adnice jsou ji\u017e zaregistrovan\u00e9", + "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API" + }, + "step": { + "user": { + "data": { + "api_key": "OpenUV API Kl\u00ed\u010d", + "elevation": "Nadmo\u0159sk\u00e1 v\u00fd\u0161ka", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka" + }, + "title": "Vypl\u0148te va\u0161e \u00fadaje" + } + }, + "title": "OpenUV" + } +} \ No newline at end of file diff --git a/homeassistant/components/openuv/.translations/ru.json b/homeassistant/components/openuv/.translations/ru.json index bd7fc3f81917ee..38e261ab6bd865 100644 --- a/homeassistant/components/openuv/.translations/ru.json +++ b/homeassistant/components/openuv/.translations/ru.json @@ -12,7 +12,7 @@ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430" }, - "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": "OpenUV" } }, "title": "OpenUV" diff --git a/homeassistant/components/owntracks/.translations/ca.json b/homeassistant/components/owntracks/.translations/ca.json new file mode 100644 index 00000000000000..438148f414c553 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ca.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." + }, + "create_entry": { + "default": "\n\nPer Android: obre [l'app OwnTracks]({android_url}), ves a prefer\u00e8ncies -> connexi\u00f3, i posa els par\u00e0metres seguents:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nPer iOS: obre [l'app OwnTracks]({ios_url}), clica l'icona (i) a dalt a l'esquerra -> configuraci\u00f3 (settings), i posa els par\u00e0metres settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nVegeu [the documentation]({docs_url}) per a m\u00e9s informaci\u00f3." + }, + "step": { + "user": { + "description": "Esteu segur que voleu configurar OwnTracks?", + "title": "Configureu OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/hu.json b/homeassistant/components/owntracks/.translations/hu.json new file mode 100644 index 00000000000000..9c4e46a28bfe06 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/hu.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?", + "title": "Owntracks be\u00e1ll\u00edt\u00e1sa" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/ko.json b/homeassistant/components/owntracks/.translations/ko.json new file mode 100644 index 00000000000000..ba264ad4b473fb --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "\n\nAndroid \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({android_url}) \uc744 \uc5f4\uace0 preferences -> connection \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({ios_url}) \uc744 \uc5f4\uace0 \uc67c\ucabd \uc0c1\ub2e8\uc758 (i) \uc544\uc774\ucf58\uc744 \ud0ed\ud558\uc5ec \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret} \n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "OwnTracks \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "OwnTracks \uc124\uc815" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/lb.json b/homeassistant/components/owntracks/.translations/lb.json new file mode 100644 index 00000000000000..146fda64b1ef4b --- /dev/null +++ b/homeassistant/components/owntracks/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "\n\nOp Android, an [der OwnTracks App]({android_url}), g\u00e9i an Preferences -> Connection. \u00c4nnert folgend Astellungen:\n- Mode: Private HTTP\n- Host {webhool_url}\n- Identification:\n - Username: ``\n - Device ID: ``\n\nOp IOS, an [der OwnTracks App]({ios_url}), klick op (i) Ikon uewen l\u00e9nks -> Settings. \u00c4nnert folgend Astellungen:\n- Mode: HTTP\n- URL: {webhool_url}\n- Turn on authentication:\n- UserID: ``\n\n{secret}\n\nKuckt w.e.g. [Dokumentatioun]({docs_url}) fir m\u00e9i Informatiounen." + }, + "step": { + "user": { + "description": "S\u00e9cher fir OwnTracks anzeriichten?", + "title": "OwnTracks ariichten" + } + }, + "title": "Owntracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/no.json b/homeassistant/components/owntracks/.translations/no.json new file mode 100644 index 00000000000000..9f86cd12cc4f52 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "\n\nP\u00e5 Android, \u00e5pne [OwnTracks appen]({android_url}), g\u00e5 til Instillinger -> tilkobling. Endre f\u00f8lgende innstillinger: \n - Modus: Privat HTTP\n - Vert: {webhook_url} \n - Identifikasjon: \n - Brukernavn: ` ` \n - Enhets-ID: ` ` \n\nP\u00e5 iOS, \u00e5pne [OwnTracks appen]({ios_url}), trykk p\u00e5 (i) ikonet \u00f8verst til venstre - > innstillinger. Endre f\u00f8lgende innstillinger: \n - Modus: HTTP \n - URL: {webhook_url} \n - Sl\u00e5 p\u00e5 autentisering \n - BrukerID: ` ` \n\n {secret} \n \n Se [dokumentasjonen]({docs_url}) for mer informasjon." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp OwnTracks?", + "title": "Sett opp OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/pl.json b/homeassistant/components/owntracks/.translations/pl.json new file mode 100644 index 00000000000000..134c49ecbbb5c1 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/pl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Wymagana jest tylko jedna instancja." + }, + "create_entry": { + "default": "Na Androida, otw\u00f3rz [the OwnTracks app]({android_url}), id\u017a do preferencje -> po\u0142aczenia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: Private HTTP\n - Host: {webhook_url}\n - Identyfikacja:\n - Nazwa u\u017cytkownika: ``\n - ID urz\u0105dzenia: ``\n\nNa iOS, otw\u00f3rz [the OwnTracks app]({ios_url}), stuknij ikon\u0119 (i) w lewym g\u00f3rnym rogu -> ustawienia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: HTTP\n - URL: {webhook_url}\n - W\u0142\u0105cz uwierzytelnianie\n - ID u\u017cytkownika: ``\n\n{secret}" + }, + "step": { + "user": { + "description": "Czy na pewno chcesz skonfigurowa\u0107 OwnTracks?", + "title": "Skonfiguruj OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/pt.json b/homeassistant/components/owntracks/.translations/pt.json new file mode 100644 index 00000000000000..91df7f5a8ea80d --- /dev/null +++ b/homeassistant/components/owntracks/.translations/pt.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria." + }, + "create_entry": { + "default": "\n\n No Android, abra [o aplicativo OwnTracks] ( {android_url} ), v\u00e1 para prefer\u00eancias - > conex\u00e3o. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP privado \n - Anfitri\u00e3o: {webhook_url} \n - Identifica\u00e7\u00e3o: \n - Nome de usu\u00e1rio: ` \n - ID do dispositivo: ` ` \n\n No iOS, abra [o aplicativo OwnTracks] ( {ios_url} ), toque no \u00edcone (i) no canto superior esquerdo - > configura\u00e7\u00f5es. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP \n - URL: {webhook_url} \n - Ativar autentica\u00e7\u00e3o \n - UserID: ` ` \n\n {secret} \n \n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais informa\u00e7\u00f5es." + }, + "step": { + "user": { + "description": "Tem certeza de que deseja configurar o OwnTracks?", + "title": "Configurar OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/ru.json b/homeassistant/components/owntracks/.translations/ru.json new file mode 100644 index 00000000000000..bb9c7f39c5b148 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\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." + }, + "create_entry": { + "default": "\u0415\u0441\u043b\u0438 \u0432\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Android, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({android_url}), \u0437\u0430\u0442\u0435\u043c preferences -> connection. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u0415\u0441\u043b\u0438 \u0432\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 iOS, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({ios_url}), \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0437\u043d\u0430\u0447\u0435\u043a (i) \u0432 \u043b\u0435\u0432\u043e\u043c \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0433\u043b\u0443 -> settings. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438." + }, + "step": { + "user": { + "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 OwnTracks?", + "title": "OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/sl.json b/homeassistant/components/owntracks/.translations/sl.json new file mode 100644 index 00000000000000..e7ae5593637536 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\n\n V Androidu odprite aplikacijo OwnTracks ( {android_url} ) in pojdite na {android_url} nastavitve - > povezave. Spremenite naslednje nastavitve: \n - Na\u010din: zasebni HTTP \n - gostitelj: {webhook_url} \n - Identifikacija: \n - Uporabni\u0161ko ime: ` ` \n - ID naprave: ` ` \n\n V iOS-ju odprite aplikacijo OwnTracks ( {ios_url} ), tapnite ikono (i) v zgornjem levem kotu - > nastavitve. Spremenite naslednje nastavitve: \n - na\u010din: HTTP \n - URL: {webhook_url} \n - Vklopite preverjanje pristnosti \n - UserID: ` ` \n\n {secret} \n \n Za ve\u010d informacij si oglejte [dokumentacijo] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Owntracks?", + "title": "Nastavite OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/zh-Hans.json b/homeassistant/components/owntracks/.translations/zh-Hans.json new file mode 100644 index 00000000000000..64a6935a9b2433 --- /dev/null +++ b/homeassistant/components/owntracks/.translations/zh-Hans.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\n\n\u5728 Android \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({android_url})\uff0c\u524d\u5f80 Preferences -> Connection\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u5728 iOS \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({ios_url})\uff0c\u70b9\u51fb\u5de6\u4e0a\u89d2\u7684 (i) \u56fe\u6807-> Settings\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" + }, + "step": { + "user": { + "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e OwnTracks \u5417\uff1f", + "title": "\u8bbe\u7f6e OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/.translations/zh-Hant.json b/homeassistant/components/owntracks/.translations/zh-Hant.json new file mode 100644 index 00000000000000..d8c195cb27738f --- /dev/null +++ b/homeassistant/components/owntracks/.translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002" + }, + "create_entry": { + "default": "\n\n\u65bc Android \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({android_url})\u3001\u9ede\u9078\u8a2d\u5b9a\uff08preferences\uff09 -> \u9023\u7dda\uff08connection\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aPrivate HTTP\n - \u4e3b\u6a5f\u7aef\uff08Host\uff09\uff1a{webhook_url}\n - Identification\uff1a\n - Username\uff1a ``\n - Device ID\uff1a``\n\n\u65bc iOS \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({ios_url})\u3001\u9ede\u9078\u5de6\u4e0a\u65b9\u7684 (i) \u5716\u793a -> \u8a2d\u5b9a\uff08settings\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aHTTP\n - URL: {webhook_url}\n - \u958b\u555f authentication\n - UserID: ``\n\n{secret}\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002" + }, + "step": { + "user": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a OwnTracks\uff1f", + "title": "\u8a2d\u5b9a OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index a5da7f5fc483dc..7dc88be976449a 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -118,9 +118,18 @@ async def async_handle_mqtt_message(topic, payload, qos): async def handle_webhook(hass, webhook_id, request): - """Handle webhook callback.""" + """Handle webhook callback. + + iOS sets the "topic" as part of the payload. + Android does not set a topic but adds headers to the request. + """ context = hass.data[DOMAIN]['context'] - message = await request.json() + + try: + message = await request.json() + except ValueError: + _LOGGER.warning('Received invalid JSON from OwnTracks') + return json_response([]) # Android doesn't populate topic if 'topic' not in message: @@ -129,11 +138,10 @@ async def handle_webhook(hass, webhook_id, request): 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 - ) + _LOGGER.warning('No topic or user found in message. If on Android,' + ' set a username in Connection -> Identification') + # Keep it as a 200 response so the incorrect packet is discarded + return json_response([]) topic_base = re.sub('/#$', '', context.mqtt_topic) message['topic'] = '{}/{}/{}'.format(topic_base, user, device) @@ -191,7 +199,7 @@ def async_valid_accuracy(self, message): async def async_see(self, **data): """Send a see message to the device tracker.""" - await self.hass.components.device_tracker.async_see(**data) + raise NotImplementedError async def async_see_beacons(self, hass, dev_id, kwargs_param): """Set active beacons to the current location.""" diff --git a/homeassistant/components/point/.translations/cs.json b/homeassistant/components/point/.translations/cs.json new file mode 100644 index 00000000000000..71f13959b412b6 --- /dev/null +++ b/homeassistant/components/point/.translations/cs.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", + "external_setup": "Point \u00fasp\u011b\u0161n\u011b nakonfigurov\u00e1n z jin\u00e9ho toku.", + "no_flows": "Mus\u00edte nakonfigurovat Point, abyste se s n\u00edm mohli ov\u011b\u0159it. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno pomoc\u00ed n\u00e1stroje Minut pro va\u0161e za\u0159\u00edzen\u00ed Point" + }, + "error": { + "follow_link": "P\u0159edt\u00edm, ne\u017e stisknete tla\u010d\u00edtko Odeslat, postupujte podle tohoto odkazu a autentizujte se", + "no_token": "Nen\u00ed ov\u011b\u0159en s Minut" + }, + "step": { + "auth": { + "description": "Postupujte podle n\u00ed\u017ee uveden\u00e9ho odkazu a P\u0159ijm\u011bte p\u0159\u00edstup k \u00fa\u010dtu Minut, pot\u00e9 se vra\u0165te zp\u011bt a stiskn\u011bte n\u00ed\u017ee Odeslat . \n\n [Odkaz]({authorization_url})", + "title": "Ov\u011b\u0159en\u00ed Point" + }, + "user": { + "data": { + "flow_impl": "Poskytovatel" + }, + "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159ov\u00e1n\u00ed chcete ov\u011b\u0159it Point.", + "title": "Poskytovatel ov\u011b\u0159en\u00ed" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json index fcc9a92bd5eede..0480b6d7195ee0 100644 --- a/homeassistant/components/point/.translations/ko.json +++ b/homeassistant/components/point/.translations/ko.json @@ -16,7 +16,7 @@ }, "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} )", + "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud574 \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": { diff --git a/homeassistant/components/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json new file mode 100644 index 00000000000000..ff7f2cdcd5676a --- /dev/null +++ b/homeassistant/components/point/.translations/nl.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "flow_impl": "Leverancier" + }, + "title": "Authenticatieleverancier" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json new file mode 100644 index 00000000000000..c5e4a7b2e86fd1 --- /dev/null +++ b/homeassistant/components/point/.translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere \u00e9n Point-konto.", + "authorize_url_fail": "Ukjent feil ved generering en autoriseringsadresse.", + "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", + "external_setup": "Point vellykket konfigurasjon fra en annen flow.", + "no_flows": "Du m\u00e5 konfigurere Point f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Vellykket godkjenning med Minut for din(e) Point enhet(er)" + }, + "error": { + "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send", + "no_token": "Ikke godkjent med Minut" + }, + "step": { + "auth": { + "description": "Vennligst f\u00f8lg lenken nedenfor og Godta tilgang til Minut-kontoen din, kom tilbake og trykk Send inn nedenfor. \n\n [Link]({authorization_url})", + "title": "Godkjenne Point" + }, + "user": { + "data": { + "flow_impl": "Tilbyder" + }, + "description": "Velg fra hvilken godkjenningsleverand\u00f8r du vil godkjenne med Point.", + "title": "Godkjenningsleverand\u00f8r" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pl.json b/homeassistant/components/point/.translations/pl.json new file mode 100644 index 00000000000000..98fa79573b0b1c --- /dev/null +++ b/homeassistant/components/point/.translations/pl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko konto Point.", + "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "external_setup": "Punkt pomy\u015blnie skonfigurowany.", + "no_flows": "Musisz skonfigurowa\u0107 Point, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcje](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono przy u\u017cyciu Minut dla urz\u0105dze\u0144 Point" + }, + "error": { + "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij", + "no_token": "Brak uwierzytelnienia za pomoc\u0105 Minut" + }, + "step": { + "auth": { + "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Minut, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", + "title": "Uwierzytelnienie Point" + }, + "user": { + "data": { + "flow_impl": "Dostawca" + }, + "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Point.", + "title": "Dostawca uwierzytelnienia" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/pt.json b/homeassistant/components/point/.translations/pt.json new file mode 100644 index 00000000000000..8831696fcff1eb --- /dev/null +++ b/homeassistant/components/point/.translations/pt.json @@ -0,0 +1,12 @@ +{ + "config": { + "error": { + "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar" + }, + "step": { + "user": { + "title": "Fornecedor de Autentica\u00e7\u00e3o" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/sl.json b/homeassistant/components/point/.translations/sl.json new file mode 100644 index 00000000000000..bd0ac2f1218a9d --- /dev/null +++ b/homeassistant/components/point/.translations/sl.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Point.", + "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "external_setup": "To\u010dka uspe\u0161no konfigurirana iz drugega toka.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Point. [Preberite navodila](https://www.home-assistant.io/components/point/)." + }, + "create_entry": { + "default": "Uspe\u0161no overjen z Minut-om za va\u0161e Point naprave" + }, + "error": { + "follow_link": "Prosimo, sledite povezavi in \u200b\u200bpreverite pristnost, preden pritisnete Po\u0161lji", + "no_token": "Ni potrjeno z Minutom" + }, + "step": { + "auth": { + "description": "Prosimo, sledite spodnji povezavi in Sprejmite dostop do va\u0161ega Minut ra\u010duna, nato se vrnite in pritisnite Po\u0161lji spodaj. \n\n [Povezava] ( {authorization_url} )", + "title": "To\u010dka za overovitev" + }, + "user": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja, ki ga \u017eelite overiti z Point-om.", + "title": "Ponudnik za preverjanje pristnosti" + } + }, + "title": "Minut Point" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json index 7d88bfeec42ae0..6b5cb91cfeb254 100644 --- a/homeassistant/components/point/.translations/zh-Hans.json +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -1,7 +1,13 @@ { "config": { "step": { + "auth": { + "description": "\u8bf7\u8bbf\u95ee\u4e0b\u65b9\u7684\u94fe\u63a5\u5e76\u5141\u8bb8\u8bbf\u95ee\u60a8\u7684 Minut \u8d26\u6237\uff0c\u7136\u540e\u56de\u6765\u70b9\u51fb\u4e0b\u9762\u7684\u63d0\u4ea4\u3002\n\n[\u94fe\u63a5]({authorization_url})" + }, "user": { + "data": { + "flow_impl": "\u63d0\u4f9b\u8005" + }, "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" } diff --git a/homeassistant/components/point/.translations/zh-Hant.json b/homeassistant/components/point/.translations/zh-Hant.json new file mode 100644 index 00000000000000..91a86f5e3dba1c --- /dev/null +++ b/homeassistant/components/point/.translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Point \u5e33\u865f\u3002", + "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "external_setup": "\u5df2\u7531\u5176\u4ed6\u6d41\u7a0b\u6210\u529f\u8a2d\u5b9a Point\u3002", + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Point \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/point/\uff09\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Minut Point \u88dd\u7f6e\u3002" + }, + "error": { + "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002", + "no_token": "Minut \u672a\u6388\u6b0a" + }, + "step": { + "auth": { + "description": "\u8acb\u4f7f\u7528\u4e0b\u65b9\u9023\u7d50\u4e26\u9ede\u9078\u63a5\u53d7\u4ee5\u5b58\u53d6 Minut \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001\u3002\n\n[Link]({authorization_url})", + "title": "\u8a8d\u8b49 Point" + }, + "user": { + "data": { + "flow_impl": "\u63d0\u4f9b\u8005" + }, + "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Point \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002", + "title": "\u8a8d\u8b49\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 index 36215da78935de..6616d6b24ec10b 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -4,6 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/point/ """ +import asyncio import logging import voluptuous as vol @@ -22,8 +23,8 @@ 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) + CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW, + SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) REQUIREMENTS = ['pypoint==1.0.6'] DEPENDENCIES = ['webhook'] @@ -33,6 +34,9 @@ CONF_CLIENT_ID = 'client_id' CONF_CLIENT_SECRET = 'client_secret' +DATA_CONFIG_ENTRY_LOCK = 'point_config_entry_lock' +CONFIG_ENTRY_IS_SETUP = 'point_config_entry_is_setup' + CONFIG_SCHEMA = vol.Schema( { DOMAIN: @@ -87,6 +91,9 @@ def token_saver(token): _LOGGER.error('Authentication Error') return False + hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[CONFIG_ENTRY_IS_SETUP] = set() + await async_setup_webhook(hass, entry, session) client = MinutPointClient(hass, entry, session) hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client}) @@ -111,7 +118,7 @@ async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry, **entry.data, }) session.update_webhook(entry.data[CONF_WEBHOOK_URL], - entry.data[CONF_WEBHOOK_ID]) + entry.data[CONF_WEBHOOK_ID], events=['*']) hass.components.webhook.async_register( DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook) @@ -153,7 +160,7 @@ class MinutPointClient(): def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry, session): """Initialize the Minut data object.""" - self._known_devices = [] + self._known_devices = set() self._hass = hass self._config_entry = config_entry self._is_available = True @@ -172,18 +179,27 @@ async def _sync(self): _LOGGER.warning("Device is unavailable") return + async def new_device(device_id, component): + """Load new device.""" + config_entries_key = '{}.{}'.format(component, DOMAIN) + async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]: + if config_entries_key not in self._hass.data[ + CONFIG_ENTRY_IS_SETUP]: + await self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component) + self._hass.data[CONFIG_ENTRY_IS_SETUP].add( + config_entries_key) + + async_dispatcher_send( + self._hass, POINT_DISCOVERY_NEW.format(component, DOMAIN), + device_id) + 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] + for component in ('sensor', 'binary_sensor'): + await new_device(device.device_id, component) + self._known_devices.add(device.device_id) async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py index 4ef21b57cd9f55..c6ba69a80831ec 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,4 +12,5 @@ EVENT_RECEIVED = 'point_webhook_received' SIGNAL_UPDATE_ENTITY = 'point_update' SIGNAL_WEBHOOK = 'point_webhook' -NEW_DEVICE = 'new_device' + +POINT_DISCOVERY_NEW = 'point_new_{}_{}' diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index bf9957f36ee6a6..3cfa0696644991 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.util import sanitize_filename import homeassistant.util.dt as dt_util -REQUIREMENTS = ['restrictedpython==4.0b6'] +REQUIREMENTS = ['restrictedpython==4.0b7'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rainmachine/.translations/cs.json b/homeassistant/components/rainmachine/.translations/cs.json new file mode 100644 index 00000000000000..919956b8c34cb6 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n", + "invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje" + }, + "step": { + "user": { + "data": { + "ip_address": "N\u00e1zev hostitele nebo adresa IP", + "password": "Heslo", + "port": "Port" + }, + "title": "Vypl\u0148te va\u0161e \u00fadaje" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/hu.json b/homeassistant/components/rainmachine/.translations/hu.json new file mode 100644 index 00000000000000..2fbb55b2833e2d --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/hu.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3" + } + } + }, + "title": "Rainmachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json new file mode 100644 index 00000000000000..9891ac50f4811f --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia" + }, + "step": { + "user": { + "data": { + "ip_address": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "port": "Port" + }, + "title": "Wprowad\u017a swoje dane" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/pt.json b/homeassistant/components/rainmachine/.translations/pt.json new file mode 100644 index 00000000000000..20f963d9dfb650 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/pt.json @@ -0,0 +1,13 @@ +{ + "config": { + "error": { + "identifier_exists": "Conta j\u00e1 registada", + "invalid_credentials": "Credenciais inv\u00e1lidas" + }, + "step": { + "user": { + "title": "Preencha as suas informa\u00e7\u00f5es" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json index 4a714f18999506..6eec3ef0ebac07 100644 --- a/homeassistant/components/rainmachine/.translations/ru.json +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -11,7 +11,7 @@ "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" } }, "title": "RainMachine" diff --git a/homeassistant/components/rainmachine/.translations/sl.json b/homeassistant/components/rainmachine/.translations/sl.json new file mode 100644 index 00000000000000..10d05fadf93853 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Ra\u010dun \u017ee registriran", + "invalid_credentials": "Neveljavne poverilnice" + }, + "step": { + "user": { + "data": { + "ip_address": "Ime gostitelja ali naslov IP", + "password": "Geslo", + "port": "port" + }, + "title": "Izpolnite svoje podatke" + } + }, + "title": "RainMachine" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 928c2ab2027036..6e1b8b68437a83 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.event import async_track_time_interval from .config_flow import configured_instances -from .const import DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN) REQUIREMENTS = ['regenmaschine==1.0.7'] @@ -33,13 +34,13 @@ SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN) +CONF_CONTROLLERS = 'controllers' CONF_PROGRAM_ID = 'program_id' CONF_ZONE_ID = 'zone_id' CONF_ZONE_RUN_TIME = 'zone_run_time' DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' DEFAULT_ICON = 'mdi:water' -DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 TYPE_FREEZE = 'freeze' @@ -97,23 +98,26 @@ SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int}) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: - vol.Schema({ - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): - cv.time_period, - vol.Optional(CONF_BINARY_SENSORS, default={}): - BINARY_SENSOR_SCHEMA, - vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, - vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, - }) - }, - extra=vol.ALLOW_EXTRA) + +CONTROLLER_SCHEMA = vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, +}) + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONTROLLERS): + vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]), + }), +}, extra=vol.ALLOW_EXTRA) async def async_setup(hass, config): @@ -127,14 +131,15 @@ async def async_setup(hass, config): conf = config[DOMAIN] - if conf[CONF_IP_ADDRESS] in configured_instances(hass): - return True + for controller in conf[CONF_CONTROLLERS]: + if controller[CONF_IP_ADDRESS] in configured_instances(hass): + continue - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={'source': SOURCE_IMPORT}, - data=conf)) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={'source': SOURCE_IMPORT}, + data=controller)) return True @@ -144,16 +149,15 @@ async def async_setup_entry(hass, 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: client = await login( - ip_address, password, websession, port=port, ssl=ssl) + config_entry.data[CONF_IP_ADDRESS], + config_entry.data[CONF_PASSWORD], + websession, + port=config_entry.data[CONF_PORT], + ssl=config_entry.data[CONF_SSL]) rainmachine = RainMachine( client, config_entry.data.get(CONF_BINARY_SENSORS, {}).get( diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index ecf497333cbd34..59b27fe0099f3f 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -7,10 +7,10 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL) + CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL) from homeassistant.helpers import aiohttp_client -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN @callback @@ -74,6 +74,12 @@ async def async_step_user(self, user_input=None): CONF_PASSWORD: 'invalid_credentials' }) + # Since the config entry doesn't allow for configuration of SSL, make + # sure it's set: + if user_input.get(CONF_SSL) is None: + user_input[CONF_SSL] = DEFAULT_SSL + + # Timedeltas are easily serializable, so store the seconds instead: scan_interval = user_input.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index ec1f0436ccb18a..e0e79e8c160ed4 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -10,5 +10,6 @@ DEFAULT_PORT = 8080 DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True TOPIC_UPDATE = 'update_{0}' diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index ddb508d128225e..15de4c3f995acd 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,6 @@ from homeassistant.helpers.entityfilter import generate_filter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from homeassistant.loader import bind_hass from . import migration, purge from .const import DATA_INSTANCE @@ -83,12 +82,6 @@ }, extra=vol.ALLOW_EXTRA) -@bind_hass -async def wait_connection_ready(hass): - """Wait till the connection is ready.""" - return await hass.data[DATA_INSTANCE].async_db_ready - - def run_information(hass, point_in_time: Optional[datetime] = None): """Return information about current run. @@ -307,14 +300,24 @@ def async_purge(now): time.sleep(CONNECT_RETRY_WAIT) try: with session_scope(session=self.get_session()) as session: - dbevent = Events.from_event(event) - session.add(dbevent) - session.flush() + try: + dbevent = Events.from_event(event) + session.add(dbevent) + session.flush() + except (TypeError, ValueError): + _LOGGER.warning( + "Event is not JSON serializable: %s", event) if event.event_type == EVENT_STATE_CHANGED: - dbstate = States.from_event(event) - dbstate.event_id = dbevent.event_id - session.add(dbstate) + try: + dbstate = States.from_event(event) + dbstate.event_id = dbevent.event_id + session.add(dbstate) + except (TypeError, ValueError): + _LOGGER.warning( + "State is not JSON serializable: %s", + event.data.get('new_state')) + updated = True except exc.OperationalError as err: diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index 915f38745a455c..a247cb3e914fec 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/scene/fibaro.py b/homeassistant/components/scene/fibaro.py new file mode 100644 index 00000000000000..7a36900f8842df --- /dev/null +++ b/homeassistant/components/scene/fibaro.py @@ -0,0 +1,35 @@ +""" +Support for Fibaro scenes. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.fibaro/ +""" +import logging + +from homeassistant.components.scene import ( + Scene) +from homeassistant.components.fibaro import ( + FIBARO_CONTROLLER, FIBARO_DEVICES, FibaroDevice) + +DEPENDENCIES = ['fibaro'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Perform the setup for Fibaro scenes.""" + if discovery_info is None: + return + + async_add_entities( + [FibaroScene(scene, hass.data[FIBARO_CONTROLLER]) + for scene in hass.data[FIBARO_DEVICES]['scene']], True) + + +class FibaroScene(FibaroDevice, Scene): + """Representation of a Fibaro scene entity.""" + + def activate(self): + """Activate the scene.""" + self.fibaro_device.start() diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py index 16c9f65420c3c6..54490af3cfac12 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,8 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS) + SERVICE_TOGGLE, SERVICE_RELOAD, STATE_ON, CONF_ALIAS, + EVENT_SCRIPT_STARTED, ATTR_NAME) from homeassistant.loader import bind_hass from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent @@ -170,8 +171,14 @@ def is_on(self): async def async_turn_on(self, **kwargs): """Turn the script on.""" + context = kwargs.get('context') + self.async_set_context(context) + self.hass.bus.async_fire(EVENT_SCRIPT_STARTED, { + ATTR_NAME: self.script.name, + ATTR_ENTITY_ID: self.entity_id, + }, context=context) await self.script.async_run( - kwargs.get(ATTR_VARIABLES), kwargs.get('context')) + kwargs.get(ATTR_VARIABLES), context) async def async_turn_off(self, **kwargs): """Turn script off.""" diff --git a/homeassistant/components/sense.py b/homeassistant/components/sense.py index 6e9204b80e1960..8ddeb3d2ecc028 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -8,20 +8,20 @@ import voluptuous as vol -from homeassistant.helpers.discovery import load_platform -from homeassistant.const import (CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT) +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform REQUIREMENTS = ['sense_energy==0.5.1'] _LOGGER = logging.getLogger(__name__) -SENSE_DATA = 'sense_data' +ACTIVE_UPDATE_RATE = 60 +DEFAULT_TIMEOUT = 5 DOMAIN = 'sense' -ACTIVE_UPDATE_RATE = 60 -DEFAULT_TIMEOUT = 5 +SENSE_DATA = 'sense_data' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -41,8 +41,8 @@ def setup(hass, config): timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = Senseable(api_timeout=timeout, - wss_timeout=timeout) + hass.data[SENSE_DATA] = Senseable( + api_timeout=timeout, wss_timeout=timeout) hass.data[SENSE_DATA].authenticate(username, password) hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE except SenseAuthenticationException: diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index be599cc295a8e3..2800b689dc6515 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, - DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_PRESSURE) + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_PRESSURE) _LOGGER = logging.getLogger(__name__) @@ -28,6 +28,7 @@ DEVICE_CLASS_HUMIDITY, # % of humidity in the air DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm) DEVICE_CLASS_TEMPERATURE, # temperature (C/F) + DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601) DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar) ] diff --git a/homeassistant/components/sensor/awair.py b/homeassistant/components/sensor/awair.py new file mode 100644 index 00000000000000..bce0acb514161c --- /dev/null +++ b/homeassistant/components/sensor/awair.py @@ -0,0 +1,227 @@ +""" +Support for the Awair indoor air quality monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.awair/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, 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, dt + +REQUIREMENTS = ['python_awair==0.0.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_SCORE = 'score' +ATTR_TIMESTAMP = 'timestamp' +ATTR_LAST_API_UPDATE = 'last_api_update' +ATTR_COMPONENT = 'component' +ATTR_VALUE = 'value' +ATTR_SENSORS = 'sensors' + +CONF_UUID = 'uuid' + +DEVICE_CLASS_PM2_5 = 'PM2.5' +DEVICE_CLASS_PM10 = 'PM10' +DEVICE_CLASS_CARBON_DIOXIDE = 'CO2' +DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = 'VOC' +DEVICE_CLASS_SCORE = 'score' + +SENSOR_TYPES = { + 'TEMP': {'device_class': DEVICE_CLASS_TEMPERATURE, + 'unit_of_measurement': TEMP_CELSIUS, + 'icon': 'mdi:thermometer'}, + 'HUMID': {'device_class': DEVICE_CLASS_HUMIDITY, + 'unit_of_measurement': '%', + 'icon': 'mdi:water-percent'}, + 'CO2': {'device_class': DEVICE_CLASS_CARBON_DIOXIDE, + 'unit_of_measurement': 'ppm', + 'icon': 'mdi:periodic-table-co2'}, + 'VOC': {'device_class': DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + 'unit_of_measurement': 'ppb', + 'icon': 'mdi:cloud'}, + # Awair docs don't actually specify the size they measure for 'dust', + # but 2.5 allows the sensor to show up in HomeKit + 'DUST': {'device_class': DEVICE_CLASS_PM2_5, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM25': {'device_class': DEVICE_CLASS_PM2_5, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM10': {'device_class': DEVICE_CLASS_PM10, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'score': {'device_class': DEVICE_CLASS_SCORE, + 'unit_of_measurement': '%', + 'icon': 'mdi:percent'}, +} + +AWAIR_QUOTA = 300 + +# This is the minimum time between throttled update calls. +# Don't bother asking us for state more often than that. +SCAN_INTERVAL = timedelta(minutes=5) + +AWAIR_DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_UUID): cv.string, +}) + +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_DEVICES): vol.All( + cv.ensure_list, [AWAIR_DEVICE_SCHEMA]), +}) + + +# Awair *heavily* throttles calls that get user information, +# and calls that get the list of user-owned devices - they +# allow 30 per DAY. So, we permit a user to provide a static +# list of devices, and they may provide the same set of information +# that the devices() call would return. However, the only thing +# used at this time is the `uuid` value. +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Connect to the Awair API and find devices.""" + from python_awair import AwairClient + + token = config[CONF_ACCESS_TOKEN] + client = AwairClient(token, session=async_get_clientsession(hass)) + + try: + all_devices = [] + devices = config.get(CONF_DEVICES, await client.devices()) + + # Try to throttle dynamically based on quota and number of devices. + throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24)) + throttle = timedelta(minutes=throttle_minutes) + + for device in devices: + _LOGGER.debug("Found awair device: %s", device) + awair_data = AwairData(client, device[CONF_UUID], throttle) + await awair_data.async_update() + for sensor in SENSOR_TYPES: + if sensor in awair_data.data: + awair_sensor = AwairSensor(awair_data, device, + sensor, throttle) + all_devices.append(awair_sensor) + + async_add_entities(all_devices, True) + return + except AwairClient.AuthError: + _LOGGER.error("Awair API access_token invalid") + except AwairClient.RatelimitError: + _LOGGER.error("Awair API ratelimit exceeded.") + except (AwairClient.QueryError, AwairClient.NotFoundError, + AwairClient.GenericError) as error: + _LOGGER.error("Unexpected Awair API error: %s", error) + + raise PlatformNotReady + + +class AwairSensor(Entity): + """Implementation of an Awair device.""" + + def __init__(self, data, device, sensor_type, throttle): + """Initialize the sensor.""" + self._uuid = device[CONF_UUID] + self._device_class = SENSOR_TYPES[sensor_type]['device_class'] + self._name = 'Awair {}'.format(self._device_class) + unit = SENSOR_TYPES[sensor_type]['unit_of_measurement'] + self._unit_of_measurement = unit + self._data = data + self._type = sensor_type + self._throttle = throttle + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self._type]['icon'] + + @property + def state(self): + """Return the state of the device.""" + return self._data.data[self._type] + + @property + def device_state_attributes(self): + """Return additional attributes.""" + return self._data.attrs + + # The Awair device should be reporting metrics in quite regularly. + # Based on the raw data from the API, it looks like every ~10 seconds + # is normal. Here we assert that the device is not available if the + # last known API timestamp is more than (3 * throttle) minutes in the + # past. It implies that either hass is somehow unable to query the API + # for new data or that the device is not checking in. Either condition + # fits the definition for 'not available'. We pick (3 * throttle) minutes + # to allow for transient errors to correct themselves. + @property + def available(self): + """Device availability based on the last update timestamp.""" + if ATTR_LAST_API_UPDATE not in self.device_state_attributes: + return False + + last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE] + return (dt.utcnow() - last_api_data) < (3 * self._throttle) + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return "{}_{}".format(self._uuid, self._type) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self._data.async_update() + + +class AwairData: + """Get data from Awair API.""" + + def __init__(self, client, uuid, throttle): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + self.attrs = {} + self.async_update = Throttle(throttle)(self._async_update) + + async def _async_update(self): + """Get the data from Awair API.""" + resp = await self._client.air_data_latest(self._uuid) + timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) + self.attrs[ATTR_LAST_API_UPDATE] = timestamp + self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] + + # The air_data_latest call only returns one item, so this should + # be safe to only process one entry. + for sensor in resp[0][ATTR_SENSORS]: + self.data[sensor[ATTR_COMPONENT]] = sensor[ATTR_VALUE] + + _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) diff --git a/homeassistant/components/sensor/blink.py b/homeassistant/components/sensor/blink.py index 804f83de4fdd65..6d3ca87c4aea48 100644 --- a/homeassistant/components/sensor/blink.py +++ b/homeassistant/components/sensor/blink.py @@ -21,7 +21,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): return data = hass.data[BLINK_DATA] devs = [] - for camera in data.sync.cameras: + for camera in data.cameras: for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: devs.append(BlinkSensor(data, camera, sensor_type)) @@ -39,7 +39,7 @@ def __init__(self, data, camera, sensor_type): self._camera_name = name self._type = sensor_type self.data = data - self._camera = data.sync.cameras[camera] + self._camera = data.cameras[camera] self._state = None self._unit_of_measurement = units self._icon = icon diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 6f7bc56cca92b1..df8b539135992d 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -119,7 +119,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not get BOM weather station from lat/lon") return - bom_data = BOMCurrentData(hass, station) + bom_data = BOMCurrentData(station) try: bom_data.update() @@ -181,9 +181,8 @@ def update(self): class BOMCurrentData: """Get data from BOM.""" - def __init__(self, hass, station_id): + def __init__(self, station_id): """Initialize the data object.""" - self._hass = hass self._zone_id, self._wmo_id = station_id.split('.') self._data = None self.last_updated = None diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index 3445eb531aa239..eae0c6e96147bd 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -71,6 +71,11 @@ def __init__(self, api, monitored_state, units: UnitSystem, if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: self._unit_of_measurement = units.temperature_unit + @property + def unique_id(self): + """Return a unique ID.""" + return "{}-{}".format(self._api.mac, self._device_attribute) + def get(self, key): """Retrieve device settings from API library cache.""" value = None diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index a3af5631a9c0d9..04c084784c73f9 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,7 +17,7 @@ from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit -REQUIREMENTS = ['Adafruit-DHT==1.3.4'] +REQUIREMENTS = ['Adafruit-DHT==1.4.0'] _LOGGER = logging.getLogger(__name__) @@ -57,9 +57,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit available_sensors = { + "AM2302": Adafruit_DHT.AM2302, "DHT11": Adafruit_DHT.DHT11, "DHT22": Adafruit_DHT.DHT22, - "AM2302": Adafruit_DHT.AM2302 } sensor = available_sensors.get(config.get(CONF_SENSOR)) pin = config.get(CONF_PIN) diff --git a/homeassistant/components/sensor/entur_public_transport.py b/homeassistant/components/sensor/entur_public_transport.py new file mode 100644 index 00000000000000..01fb22f675c5be --- /dev/null +++ b/homeassistant/components/sensor/entur_public_transport.py @@ -0,0 +1,193 @@ +""" +Real-time information about public transport departures in Norway. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.entur_public_transport/ +""" +from datetime import datetime, timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, + CONF_SHOW_ON_MAP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt_util + +REQUIREMENTS = ['enturclient==0.1.0'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_NEXT_UP_IN = 'next_due_in' + +API_CLIENT_NAME = 'homeassistant-homeassistant' + +CONF_ATTRIBUTION = "Data provided by entur.org under NLOD." +CONF_STOP_IDS = 'stop_ids' +CONF_EXPAND_PLATFORMS = 'expand_platforms' + +DEFAULT_NAME = 'Entur' +DEFAULT_ICON_KEY = 'bus' + +ICONS = { + 'air': 'mdi:airplane', + 'bus': 'mdi:bus', + 'rail': 'mdi:train', + 'water': 'mdi:ferry', +} + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STOP_IDS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_EXPAND_PLATFORMS, default=True): cv.boolean, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean, +}) + + +def due_in_minutes(timestamp: str) -> str: + """Get the time in minutes from a timestamp. + + The timestamp should be in the format + year-month-yearThour:minute:second+timezone + """ + if timestamp is None: + return None + diff = datetime.strptime( + timestamp, "%Y-%m-%dT%H:%M:%S%z") - dt_util.now() + + return str(int(diff.total_seconds() / 60)) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Entur public transport sensor.""" + from enturclient import EnturPublicTransportData + from enturclient.consts import CONF_NAME as API_NAME + + expand = config.get(CONF_EXPAND_PLATFORMS) + name = config.get(CONF_NAME) + show_on_map = config.get(CONF_SHOW_ON_MAP) + stop_ids = config.get(CONF_STOP_IDS) + + stops = [s for s in stop_ids if "StopPlace" in s] + quays = [s for s in stop_ids if "Quay" in s] + + data = EnturPublicTransportData(API_CLIENT_NAME, stops, quays, expand) + data.update() + + proxy = EnturProxy(data) + + entities = [] + for item in data.all_stop_places_quays(): + try: + given_name = "{} {}".format( + name, data.get_stop_info(item)[API_NAME]) + except KeyError: + given_name = "{} {}".format(name, item) + + entities.append( + EnturPublicTransportSensor(proxy, given_name, item, show_on_map)) + + add_entities(entities, True) + + +class EnturProxy: + """Proxy for the Entur client. + + Ensure throttle to not hit rate limiting on the API. + """ + + def __init__(self, api): + """Initialize the proxy.""" + self._api = api + + @Throttle(SCAN_INTERVAL) + def update(self) -> None: + """Update data in client.""" + self._api.update() + + def get_stop_info(self, stop_id: str) -> dict: + """Get info about specific stop place.""" + return self._api.get_stop_info(stop_id) + + +class EnturPublicTransportSensor(Entity): + """Implementation of a Entur public transport sensor.""" + + def __init__( + self, api: EnturProxy, name: str, stop: str, show_on_map: bool): + """Initialize the sensor.""" + from enturclient.consts import ATTR_STOP_ID + + self.api = api + self._stop = stop + self._show_on_map = show_on_map + self._name = name + self._data = None + self._state = None + self._icon = ICONS[DEFAULT_ICON_KEY] + self._attributes = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_STOP_ID: self._stop, + } + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return 'min' + + @property + def icon(self) -> str: + """Icon to use in the frontend.""" + return self._icon + + def update(self) -> None: + """Get the latest data and update the states.""" + from enturclient.consts import ( + ATTR, ATTR_EXPECTED_AT, ATTR_NEXT_UP_AT, CONF_LOCATION, + CONF_LATITUDE as LAT, CONF_LONGITUDE as LONG, CONF_TRANSPORT_MODE) + + self.api.update() + + self._data = self.api.get_stop_info(self._stop) + if self._data is not None: + attrs = self._data[ATTR] + self._attributes.update(attrs) + + if ATTR_NEXT_UP_AT in attrs: + self._attributes[ATTR_NEXT_UP_IN] = \ + due_in_minutes(attrs[ATTR_NEXT_UP_AT]) + + if CONF_LOCATION in self._data and self._show_on_map: + self._attributes[CONF_LATITUDE] = \ + self._data[CONF_LOCATION][LAT] + self._attributes[CONF_LONGITUDE] = \ + self._data[CONF_LOCATION][LONG] + + if ATTR_EXPECTED_AT in attrs: + self._state = due_in_minutes(attrs[ATTR_EXPECTED_AT]) + else: + self._state = None + + self._icon = ICONS.get( + self._data[CONF_TRANSPORT_MODE], ICONS[DEFAULT_ICON_KEY]) diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 761dc7c6a00e7a..8e975c48574239 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -10,9 +10,8 @@ from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util REQUIREMENTS = ['fastdotcom==0.0.3'] @@ -51,7 +50,7 @@ def update(call=None): hass.services.register(DOMAIN, 'update_fastdotcom', update) -class SpeedtestSensor(Entity): +class SpeedtestSensor(RestoreEntity): """Implementation of a FAst.com sensor.""" def __init__(self, speedtest_data): @@ -86,7 +85,8 @@ def update(self): async def async_added_to_hass(self): """Handle entity which will be added.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/sensor/ihc.py b/homeassistant/components/sensor/ihc.py index f5140838a7a30a..f5a45599bb75a7 100644 --- a/homeassistant/components/sensor/ihc.py +++ b/homeassistant/components/sensor/ihc.py @@ -3,56 +3,34 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.ihc/ """ -import voluptuous as vol - from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) + IHC_DATA, IHC_CONTROLLER, IHC_INFO) from homeassistant.components.ihc.ihcdevice import IHCDevice -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_SENSORS, - TEMP_CELSIUS) -import homeassistant.helpers.config_validation as cv + CONF_UNIT_OF_MEASUREMENT) from homeassistant.helpers.entity import Entity DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SENSORS, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT, - default=TEMP_CELSIUS): cv.string - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC sensor platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product_cfg = device['product_cfg'] - product = device['product'] - sensor = IHCSensor(ihc_controller, name, ihc_id, info, - product_cfg[CONF_UNIT_OF_MEASUREMENT], - product) - devices.append(sensor) - else: - sensors = config[CONF_SENSORS] - for sensor_cfg in sensors: - ihc_id = sensor_cfg[CONF_ID] - name = sensor_cfg[CONF_NAME] - unit = sensor_cfg[CONF_UNIT_OF_MEASUREMENT] - sensor = IHCSensor(ihc_controller, name, ihc_id, info, unit) - devices.append(sensor) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + unit = product_cfg[CONF_UNIT_OF_MEASUREMENT] + sensor = IHCSensor(ihc_controller, name, ihc_id, info, + unit, product) + devices.append(sensor) add_entities(devices) diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index 87e2bdb5c9cff2..0fc31ef273ff28 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['influxdb==5.0.0'] +REQUIREMENTS = ['influxdb==5.2.0'] DEFAULT_HOST = 'localhost' DEFAULT_PORT = 8086 diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 225ed07a6227a7..7d0908c5645484 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -16,7 +16,7 @@ from homeassistant.components.mqtt import ( ATTR_DISCOVERY_HASH, CONF_AVAILABILITY_TOPIC, CONF_STATE_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 from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA from homeassistant.const import ( @@ -48,8 +48,8 @@ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol + # Integrations should never expose unique_id through configuration. + # This is an exception because MQTT is a message transport, not a protocol. vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -58,7 +58,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT sensors through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities) + await _async_setup_entity(config, async_add_entities) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -66,7 +66,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect(hass, @@ -74,73 +74,62 @@ async def async_discover_sensor(discovery_payload): async_discover_sensor) -async def _async_setup_entity(hass: HomeAssistantType, config: ConfigType, - async_add_entities, discovery_hash=None): +async def _async_setup_entity(config: ConfigType, async_add_entities, + discovery_hash=None): """Set up MQTT sensor.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - async_add_entities([MqttSensor( - config.get(CONF_NAME), - config.get(CONF_STATE_TOPIC), - config.get(CONF_QOS), - config.get(CONF_UNIT_OF_MEASUREMENT), - config.get(CONF_FORCE_UPDATE), - config.get(CONF_EXPIRE_AFTER), - config.get(CONF_ICON), - config.get(CONF_DEVICE_CLASS), - value_template, - config.get(CONF_JSON_ATTRS), - config.get(CONF_UNIQUE_ID), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_DEVICE), - discovery_hash, - )]) + async_add_entities([MqttSensor(config, discovery_hash)]) class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, Entity): """Representation of a sensor that can be updated using MQTT.""" - def __init__(self, name, state_topic, qos, unit_of_measurement, - force_update, expire_after, icon, device_class: Optional[str], - value_template, json_attributes, unique_id: Optional[str], - availability_topic, payload_available, payload_not_available, - device_config: Optional[ConfigType], discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the sensor.""" - MqttAvailability.__init__(self, availability_topic, qos, - payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) - MqttEntityDeviceInfo.__init__(self, device_config) + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = STATE_UNKNOWN - self._name = name - self._state_topic = state_topic - self._qos = qos - self._unit_of_measurement = unit_of_measurement - self._force_update = force_update - self._template = value_template - self._expire_after = expire_after - self._icon = icon - self._device_class = device_class + self._sub_state = None self._expiration_trigger = None - self._json_attributes = set(json_attributes) - self._unique_id = unique_id self._attributes = None - self._discovery_hash = discovery_hash + + availability_topic = config.get(CONF_AVAILABILITY_TOPIC) + payload_available = config.get(CONF_PAYLOAD_AVAILABLE) + payload_not_available = config.get(CONF_PAYLOAD_NOT_AVAILABLE) + qos = config.get(CONF_QOS) + device_config = config.get(CONF_DEVICE) + + MqttAvailability.__init__(self, availability_topic, 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 to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() + await self._subscribe_topics() + + async def discovery_update(self, discovery_payload): + """Handle updated discovery message.""" + config = PLATFORM_SCHEMA(discovery_payload) + self._config = config + await self.availability_discovery_update(config) + await self._subscribe_topics() + self.async_schedule_update_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass @callback def message_received(topic, payload, qos): """Handle new MQTT messages.""" # auto-expire enabled? - if self._expire_after is not None and self._expire_after > 0: + expire_after = self._config.get(CONF_EXPIRE_AFTER) + if expire_after is not None and expire_after > 0: # Reset old trigger if self._expiration_trigger: self._expiration_trigger() @@ -148,18 +137,19 @@ def message_received(topic, payload, qos): # Set new trigger expiration_at = ( - dt_util.utcnow() + timedelta(seconds=self._expire_after)) + dt_util.utcnow() + timedelta(seconds=expire_after)) self._expiration_trigger = async_track_point_in_utc_time( self.hass, self.value_is_expired, expiration_at) - if self._json_attributes: + json_attributes = set(self._config.get(CONF_JSON_ATTRS)) + if json_attributes: self._attributes = {} try: json_dict = json.loads(payload) if isinstance(json_dict, dict): attrs = {k: json_dict[k] for k in - self._json_attributes & json_dict.keys()} + json_attributes & json_dict.keys()} self._attributes = attrs else: _LOGGER.warning("JSON result was not a dictionary") @@ -167,14 +157,22 @@ def message_received(topic, payload, qos): _LOGGER.warning("MQTT payload could not be parsed as JSON") _LOGGER.debug("Erroneous JSON: %s", payload) - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload, self._state) self._state = payload self.async_schedule_update_ha_state() - await mqtt.async_subscribe(self.hass, self._state_topic, - message_received, self._qos) + self._sub_state = await subscription.async_subscribe_topics( + self.hass, self._sub_state, + {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': message_received, + 'qos': self._config.get(CONF_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) @callback def value_is_expired(self, *_): @@ -191,17 +189,17 @@ def should_poll(self): @property def name(self): """Return the name of the sensor.""" - return self._name + return self._config.get(CONF_NAME) @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return self._unit_of_measurement + return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property def force_update(self): """Force update.""" - return self._force_update + return self._config.get(CONF_FORCE_UPDATE) @property def state(self): @@ -221,9 +219,9 @@ def unique_id(self): @property def icon(self): """Return the icon.""" - return self._icon + return self._config.get(CONF_ICON) @property def device_class(self) -> Optional[str]: """Return the device class of the sensor.""" - return self._device_class + return self._config.get(CONF_DEVICE_CLASS) diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index f709e0169cf531..7590bccb5431ab 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -63,6 +63,11 @@ vol.Optional(CONF_MODULES): MODULE_SCHEMA, }) +MODULE_TYPE_OUTDOOR = 'NAModule1' +MODULE_TYPE_WIND = 'NAModule2' +MODULE_TYPE_RAIN = 'NAModule3' +MODULE_TYPE_INDOOR = 'NAModule4' + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the available Netatmo weather sensors.""" @@ -74,7 +79,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: if CONF_MODULES in config: # Iterate each module - for module_name, monitored_conditions in\ + for module_name, monitored_conditions in \ config[CONF_MODULES].items(): # Test if module exists if module_name not in data.get_module_names(): @@ -85,7 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev.append(NetAtmoSensor(data, module_name, variable)) else: for module_name in data.get_module_names(): - for variable in\ + for variable in \ data.station_data.monitoredConditions(module_name): if variable in SENSOR_TYPES.keys(): dev.append(NetAtmoSensor(data, module_name, variable)) @@ -112,9 +117,11 @@ def __init__(self, netatmo_data, module_name, sensor_type): self._device_class = SENSOR_TYPES[self.type][3] self._icon = SENSOR_TYPES[self.type][2] self._unit_of_measurement = SENSOR_TYPES[self.type][1] - module_id = self.netatmo_data.\ + self._module_type = self.netatmo_data. \ + station_data.moduleByName(module=module_name)['type'] + module_id = self.netatmo_data. \ station_data.moduleByName(module=module_name)['_id'] - self.module_id = module_id[1] + self._unique_id = '{}-{}'.format(module_id, self.type) @property def name(self): @@ -141,6 +148,11 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def unique_id(self): + """Return the unique ID for this sensor.""" + return self._unique_id + def update(self): """Get the latest data from NetAtmo API and updates the states.""" self.netatmo_data.update() @@ -169,7 +181,8 @@ def update(self): self._state = round(data['Pressure'], 1) elif self.type == 'battery_lvl': self._state = data['battery_vp'] - elif self.type == 'battery_vp' and self.module_id == '6': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_WIND): if data['battery_vp'] >= 5590: self._state = "Full" elif data['battery_vp'] >= 5180: @@ -180,7 +193,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4360: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '5': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_RAIN): if data['battery_vp'] >= 5500: self._state = "Full" elif data['battery_vp'] >= 5000: @@ -191,7 +205,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4000: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '3': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_INDOOR): if data['battery_vp'] >= 5640: self._state = "Full" elif data['battery_vp'] >= 5280: @@ -202,7 +217,8 @@ def update(self): self._state = "Low" elif data['battery_vp'] < 4560: self._state = "Very Low" - elif self.type == 'battery_vp' and self.module_id == '2': + elif (self.type == 'battery_vp' and + self._module_type == MODULE_TYPE_OUTDOOR): if data['battery_vp'] >= 5500: self._state = "Full" elif data['battery_vp'] >= 5000: @@ -304,6 +320,20 @@ def get_module_names(self): self.update() return self.data.keys() + def _detect_platform_type(self): + """Return the XXXData object corresponding to the specified platform. + + The return can be a WeatherStationData or a HomeCoachData. + """ + import pyatmo + for data_class in [pyatmo.WeatherStationData, pyatmo.HomeCoachData]: + try: + station_data = data_class(self.auth) + _LOGGER.debug("%s detected!", str(data_class.__name__)) + return station_data + except TypeError: + continue + def update(self): """Call the Netatmo API to update the data. @@ -316,12 +346,9 @@ def update(self): return try: - import pyatmo - try: - self.station_data = pyatmo.WeatherStationData(self.auth) - except TypeError: - _LOGGER.error("Failed to connect to NetAtmo") - return # finally statement will be executed + self.station_data = self._detect_platform_type() + if not self.station_data: + raise Exception("No Weather nor HomeCoach devices found") if self.station is not None: self.data = self.station_data.lastData( diff --git a/homeassistant/components/sensor/point.py b/homeassistant/components/sensor/point.py index 0c099c8873e722..1bb46827602da5 100644 --- a/homeassistant/components/sensor/point.py +++ b/homeassistant/components/sensor/point.py @@ -6,13 +6,15 @@ """ import logging -from homeassistant.components.point import MinutPointEntity +from homeassistant.components.point import ( + DOMAIN as PARENT_DOMAIN, MinutPointEntity) from homeassistant.components.point.const import ( - DOMAIN as POINT_DOMAIN, NEW_DEVICE) + DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import parse_datetime _LOGGER = logging.getLogger(__name__) @@ -29,10 +31,15 @@ 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) + async def async_discover_sensor(device_id): + """Discover and add a discovered sensor.""" + 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) + + async_dispatcher_connect( + hass, POINT_DISCOVERY_NEW.format(PARENT_DOMAIN, POINT_DOMAIN), + async_discover_sensor) class MinutPointSensor(MinutPointEntity): diff --git a/homeassistant/components/sensor/qbittorrent.py b/homeassistant/components/sensor/qbittorrent.py new file mode 100644 index 00000000000000..8718f3a9d7449a --- /dev/null +++ b/homeassistant/components/sensor/qbittorrent.py @@ -0,0 +1,142 @@ +""" +Support for monitoring the qBittorrent API. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.qbittorrent/ +""" +import logging + +import voluptuous as vol + +from requests.exceptions import RequestException + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, STATE_IDLE) +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady + +REQUIREMENTS = ['python-qbittorrent==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TYPE_CURRENT_STATUS = 'current_status' +SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed' +SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed' + +DEFAULT_NAME = 'qBittorrent' + +SENSOR_TYPES = { + SENSOR_TYPE_CURRENT_STATUS: ['Status', None], + SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'], + SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.url, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string +}) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the qBittorrent sensors.""" + from qbittorrent.client import Client, LoginRequired + + try: + client = Client(config[CONF_URL]) + client.login(config[CONF_USERNAME], config[CONF_PASSWORD]) + except LoginRequired: + _LOGGER.error("Invalid authentication") + return + except RequestException: + _LOGGER.error("Connection failed") + raise PlatformNotReady + + name = config.get(CONF_NAME) + + dev = [] + for sensor_type in SENSOR_TYPES: + sensor = QBittorrentSensor(sensor_type, client, name, LoginRequired) + dev.append(sensor) + + async_add_entities(dev, True) + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +class QBittorrentSensor(Entity): + """Representation of an qBittorrent sensor.""" + + def __init__(self, sensor_type, qbittorrent_client, + client_name, exception): + """Initialize the qBittorrent sensor.""" + self._name = SENSOR_TYPES[sensor_type][0] + self.client = qbittorrent_client + self.type = sensor_type + self.client_name = client_name + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._available = False + self._exception = exception + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format(self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return true if device is available.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data from qBittorrent and updates the state.""" + try: + data = self.client.sync() + self._available = True + except RequestException: + _LOGGER.error("Connection lost") + self._available = False + return + except self._exception: + _LOGGER.error("Invalid authentication") + return + + if data is None: + return + + download = data['server_state']['dl_info_speed'] + upload = data['server_state']['up_info_speed'] + + if self.type == SENSOR_TYPE_CURRENT_STATUS: + if upload > 0 and download > 0: + self._state = 'up_down' + elif upload > 0 and download == 0: + self._state = 'seeding' + elif upload == 0 and download > 0: + self._state = 'downloading' + else: + self._state = STATE_IDLE + + elif self.type == SENSOR_TYPE_DOWNLOAD_SPEED: + self._state = format_speed(download) + elif self.type == SENSOR_TYPE_UPLOAD_SPEED: + self._state = format_speed(upload) diff --git a/homeassistant/components/sensor/rtorrent.py b/homeassistant/components/sensor/rtorrent.py index 7822bcd58b798b..8ec6a45b639ca5 100644 --- a/homeassistant/components/sensor/rtorrent.py +++ b/homeassistant/components/sensor/rtorrent.py @@ -110,11 +110,11 @@ def update(self): if self.type == SENSOR_TYPE_CURRENT_STATUS: if self.data: if upload > 0 and download > 0: - self._state = 'Up/Down' + self._state = 'up_down' elif upload > 0 and download == 0: - self._state = 'Seeding' + self._state = 'seeding' elif upload == 0 and download > 0: - self._state = 'Downloading' + self._state = 'downloading' else: self._state = STATE_IDLE else: diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index b494257beb794a..769b3a9e148cff 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -4,27 +4,31 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sense/ """ -import logging - from datetime import timedelta +import logging +from homeassistant.components.sense import SENSE_DATA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from homeassistant.components.sense import SENSE_DATA - -DEPENDENCIES = ['sense'] _LOGGER = logging.getLogger(__name__) ACTIVE_NAME = 'Energy' -PRODUCTION_NAME = 'Production' +ACTIVE_TYPE = 'active' + CONSUMPTION_NAME = 'Usage' -ACTIVE_TYPE = 'active' +DEPENDENCIES = ['sense'] + +ICON = 'mdi:flash' + +MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) + +PRODUCTION_NAME = 'Production' class SensorConfig: - """Data structure holding sensor config.""" + """Data structure holding sensor configuration.""" def __init__(self, name, sensor_type): """Sensor name and type to pass to API.""" @@ -33,19 +37,17 @@ def __init__(self, name, sensor_type): # Sensor types/ranges -SENSOR_TYPES = {'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), - 'daily': SensorConfig('Daily', 'DAY'), - 'weekly': SensorConfig('Weekly', 'WEEK'), - 'monthly': SensorConfig('Monthly', 'MONTH'), - 'yearly': SensorConfig('Yearly', 'YEAR')} +SENSOR_TYPES = { + 'active': SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), + 'daily': SensorConfig('Daily', 'DAY'), + 'weekly': SensorConfig('Weekly', 'WEEK'), + 'monthly': SensorConfig('Monthly', 'MONTH'), + 'yearly': SensorConfig('Yearly', 'YEAR'), +} # Production/consumption variants SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -ICON = 'mdi:flash' - -MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Sense sensor.""" @@ -73,8 +75,8 @@ def update_active(): update_call = update_active else: update_call = update_trends - devices.append(Sense(data, name, sensor_type, - is_production, update_call)) + devices.append(Sense( + data, name, sensor_type, is_production, update_call)) add_entities(devices) @@ -83,9 +85,9 @@ class Sense(Entity): """Implementation of a Sense energy sensor.""" def __init__(self, data, name, sensor_type, is_production, update_call): - """Initialize the sensor.""" + """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME - self._name = "%s %s" % (name, name_type) + self._name = "{} {}".format(name, name_type) self._data = data self._sensor_type = sensor_type self.update_sensor = update_call @@ -132,6 +134,6 @@ def update(self): else: self._state = round(self._data.active_power) else: - state = self._data.get_trend(self._sensor_type, - self._is_production) + state = self._data.get_trend( + self._sensor_type, self._is_production) self._state = round(state, 1) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index b4c869e7267104..7c5dba3b0e1102 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.1.0'] +REQUIREMENTS = ['py17track==2.1.1'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' @@ -25,6 +25,7 @@ ATTR_ORIGIN_COUNTRY = 'origin_country' ATTR_PACKAGE_TYPE = 'package_type' ATTR_TRACKING_INFO_LANGUAGE = 'tracking_info_language' +ATTR_TRACKING_NUMBER = 'tracking_number' CONF_SHOW_ARCHIVED = 'show_archived' CONF_SHOW_DELIVERED = 'show_delivered' @@ -116,7 +117,7 @@ def icon(self): @property def name(self): """Return the name.""" - return 'Packages {0}'.format(self._status) + return '17track Packages {0}'.format(self._status) @property def state(self): @@ -154,8 +155,10 @@ def __init__(self, data, package): ATTR_ORIGIN_COUNTRY: package.origin_country, ATTR_PACKAGE_TYPE: package.package_type, ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, } self._data = data + self._friendly_name = package.friendly_name self._state = package.status self._tracking_number = package.tracking_number @@ -180,7 +183,10 @@ def icon(self): @property def name(self): """Return the name.""" - return self._tracking_number + name = self._friendly_name + if not name: + name = self._tracking_number + return '17track Package: {0}'.format(name) @property def state(self): diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 718e4f6fb0d49f..b9997345c36d5e 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_USERNAME, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index a08eec56e1758d..f834b51b064aeb 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -11,9 +11,8 @@ from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_change -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util REQUIREMENTS = ['speedtest-cli==2.0.2'] @@ -76,7 +75,7 @@ def update(call=None): hass.services.register(DOMAIN, 'update_speedtest', update) -class SpeedtestSensor(Entity): +class SpeedtestSensor(RestoreEntity): """Implementation of a speedtest.net sensor.""" def __init__(self, speedtest_data, sensor_type): @@ -137,7 +136,8 @@ def update(self): async def async_added_to_hass(self): """Handle all entity which are about to be added.""" - state = await async_get_last_state(self.hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if not state: return self._state = state.state diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index e7a35b5fdf0d33..e011121f4a2ce4 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -120,7 +120,7 @@ def async_stats_sensor_startup(event): 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 + # Only use the database if it's configured self.hass.async_create_task( self._async_initialize_from_database() ) @@ -129,11 +129,20 @@ def async_stats_sensor_startup(event): EVENT_HOMEASSISTANT_START, async_stats_sensor_startup) def _add_state_to_queue(self, new_state): + """Add the state to the queue.""" + if new_state.state == STATE_UNKNOWN: + return + try: - self.states.append(float(new_state.state)) + if self.is_binary: + self.states.append(new_state.state) + else: + self.states.append(float(new_state.state)) + self.ages.append(new_state.last_updated) except ValueError: - pass + _LOGGER.error("%s: parsing error, expected number and received %s", + self.entity_id, new_state.state) @property def name(self): diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 27a7f083fbe3a3..212602aa72cece 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -41,7 +41,6 @@ 'packets_out': ['Packets out', ' ', 'mdi:server-network'], 'process': ['Process', ' ', 'mdi:memory'], 'processor_use': ['Processor use', '%', 'mdi:memory'], - 'since_last_boot': ['Since last boot', '', 'mdi:clock'], 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk'], 'swap_use': ['Swap use', 'MiB', 'mdi:harddisk'], 'swap_use_percent': ['Swap use (percent)', '%', 'mdi:harddisk'], @@ -174,10 +173,7 @@ def update(self): elif self.type == 'last_boot': self._state = dt_util.as_local( dt_util.utc_from_timestamp(psutil.boot_time()) - ).date().isoformat() - elif self.type == 'since_last_boot': - self._state = dt_util.utcnow() - dt_util.utc_from_timestamp( - psutil.boot_time()) + ).isoformat() elif self.type == 'load_1m': self._state = os.getloadavg()[0] elif self.type == 'load_5m': diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 4676e08a247413..4afff115b9df4a 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -6,7 +6,8 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive +from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) @@ -27,8 +28,8 @@ SENSOR_TYPE_BAROMETRIC_PRESSURE = 'barpress' SENSOR_TYPES = { - SENSOR_TYPE_TEMPERATURE: ['Temperature', TEMP_CELSIUS, None, - DEVICE_CLASS_TEMPERATURE], + SENSOR_TYPE_TEMPERATURE: + ['Temperature', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], SENSOR_TYPE_HUMIDITY: ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], SENSOR_TYPE_RAINRATE: ['Rain rate', 'mm/h', 'mdi:water', None], SENSOR_TYPE_RAINTOTAL: ['Rain total', 'mm', 'mdi:water', None], @@ -39,7 +40,7 @@ SENSOR_TYPE_WATT: ['Power', 'W', '', None], SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE], SENSOR_TYPE_DEW_POINT: - ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], + ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], SENSOR_TYPE_BAROMETRIC_PRESSURE: ['Barometric Pressure', 'kPa', '', None], } @@ -48,7 +49,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tellstick sensors.""" if discovery_info is None: return - add_entities(TelldusLiveSensor(hass, sensor) for sensor in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities( + TelldusLiveSensor(client, sensor) for sensor in discovery_info) class TelldusLiveSensor(TelldusLiveEntity): @@ -87,9 +90,7 @@ def _value_as_humidity(self): @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format( - super().name, - self.quantity_name or '') + return '{} {}'.format(super().name, self.quantity_name or '').strip() @property def state(self): @@ -127,3 +128,8 @@ def device_class(self): """Return the device class.""" return SENSOR_TYPES[self._type][3] \ if self._type in SENSOR_TYPES else None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "-".join(self._id[0:2]) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 703f2bbbd172bd..0ba470ca778e75 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -35,17 +35,21 @@ async def async_setup_platform(hass, config, async_add_entities, tibber_connection = hass.data.get(TIBBER_DOMAIN) - try: - dev = [] - for home in tibber_connection.get_homes(): + dev = [] + for home in tibber_connection.get_homes(): + try: await home.update_info() - dev.append(TibberSensorElPrice(home)) - if home.has_real_time_consumption: - dev.append(TibberSensorRT(home)) - except (asyncio.TimeoutError, aiohttp.ClientError): - raise PlatformNotReady() + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout connecting to Tibber home: %s ", err) + raise PlatformNotReady() + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Tibber home: %s ", err) + raise PlatformNotReady() + dev.append(TibberSensorElPrice(home)) + if home.has_real_time_consumption: + dev.append(TibberSensorRT(home)) - async_add_entities(dev, True) + async_add_entities(dev, False) class TibberSensorElPrice(Entity): @@ -152,7 +156,7 @@ def _update_current_price(self): 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['avg_price'] = round(sum_price / num, 3) self._device_state_attributes['min_price'] = min_price return state is not None @@ -187,7 +191,11 @@ async def _async_callback(self, payload): if live_measurement is None: return self._state = live_measurement.pop('power', None) - self._device_state_attributes = live_measurement + for key, value in live_measurement.items(): + if value is None: + continue + self._device_state_attributes[key] = value + self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index a3f0c55b954d94..65b996a5bd5a1e 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,19 +6,18 @@ """ import logging -from math import floor -from homeassistant.components.volvooncall import ( - VolvoEntity, RESOURCES, CONF_SCANDINAVIAN_MILES) +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY _LOGGER = logging.getLogger(__name__) -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 Volvo sensors.""" if discovery_info is None: return - add_entities([VolvoSensor(hass, *discovery_info)]) + async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)]) class VolvoSensor(VolvoEntity): @@ -26,38 +25,10 @@ class VolvoSensor(VolvoEntity): @property def state(self): - """Return the state of the sensor.""" - val = getattr(self.vehicle, self._attribute) - - if val is None: - return val - - if self._attribute == 'odometer': - val /= 1000 # m -> km - - if 'mil' in self.unit_of_measurement: - val /= 10 # km -> mil - - if self._attribute == 'average_fuel_consumption': - val /= 10 # L/1000km -> L/100km - if 'mil' in self.unit_of_measurement: - return round(val, 2) - return round(val, 1) - if self._attribute == 'distance_to_empty': - return int(floor(val)) - return int(round(val)) + """Return the state.""" + return self.instrument.state @property def unit_of_measurement(self): """Return the unit of measurement.""" - unit = RESOURCES[self._attribute][3] - if self._state.config[CONF_SCANDINAVIAN_MILES] and 'km' in unit: - if self._attribute == 'average_fuel_consumption': - return 'L/mil' - return unit.replace('km', 'mil') - return unit - - @property - def icon(self): - """Return the icon.""" - return RESOURCES[self._attribute][2] + return self.instrument.unit diff --git a/homeassistant/components/sensor/waterfurnace.py b/homeassistant/components/sensor/waterfurnace.py index 60da761cf75c9e..65632f51494e69 100644 --- a/homeassistant/components/sensor/waterfurnace.py +++ b/homeassistant/components/sensor/waterfurnace.py @@ -42,6 +42,14 @@ def __init__(self, friendly_name, field, icon="mdi:gauge", "mdi:water-percent", "%"), WFSensorConfig("Humidity", "tstatrelativehumidity", "mdi:water-percent", "%"), + WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", "W"), + WFSensorConfig("Fan Power", "fanpower", "mdi:flash", "W"), + WFSensorConfig("Aux Power", "auxpower", "mdi:flash", "W"), + WFSensorConfig("Loop Pump Power", "looppumppower", "mdi:flash", "W"), + WFSensorConfig("Compressor Speed", "actualcompressorspeed", + "mdi:speedometer"), + WFSensorConfig("Fan Speed", "airflowcurrentspeed", "mdi:fan"), + ] diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index dddf7b23922e2a..ef5ed1d5c38051 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 9a9de0d6cf2abe..80aad9ac937272 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,8 +7,12 @@ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.components import zha +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import convert as convert_temperature _LOGGER = logging.getLogger(__name__) @@ -18,13 +22,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up Zigbee Home Automation sensors.""" - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return + """Old way of setting up Zigbee Home Automation sensors.""" + pass - sensor = await make_sensor(discovery_info) - async_add_entities([sensor], update_before_add=True) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation sensor from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if sensors is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + sensors.values()) + del hass.data[DATA_ZHA][DOMAIN] + + +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA sensors.""" + entities = [] + for discovery_info in discovery_infos: + entities.append(await make_sensor(discovery_info)) + + async_add_entities(entities, update_before_add=True) async def make_sensor(discovery_info): @@ -56,7 +82,7 @@ async def make_sensor(discovery_info): if discovery_info['new_join']: cluster = list(in_clusters.values())[0] - await zha.configure_reporting( + await helpers.configure_reporting( sensor.entity_id, cluster, sensor.value_attribute, reportable_change=sensor.min_reportable_change ) @@ -64,7 +90,7 @@ async def make_sensor(discovery_info): return sensor -class Sensor(zha.Entity): +class Sensor(ZhaEntity): """Base ZHA sensor.""" _domain = DOMAIN @@ -92,7 +118,7 @@ def attribute_updated(self, attribute, value): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read( + result = await helpers.safe_read( list(self._in_clusters.values())[0], [self.value_attribute], allow_cache=False, @@ -224,7 +250,7 @@ async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("%s async_update", self.entity_id) - result = await zha.safe_read( + result = await helpers.safe_read( self._endpoint.electrical_measurement, ['active_power'], allow_cache=False, only_cache=(not self._initialized)) self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index ad4680982b424c..2ebd80c3de09d7 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -40,6 +40,7 @@ WS_TYPE_SHOPPING_LIST_ITEMS = 'shopping_list/items' WS_TYPE_SHOPPING_LIST_ADD_ITEM = 'shopping_list/items/add' WS_TYPE_SHOPPING_LIST_UPDATE_ITEM = 'shopping_list/items/update' +WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS = 'shopping_list/items/clear' SCHEMA_WEBSOCKET_ITEMS = \ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ @@ -60,6 +61,11 @@ vol.Optional('complete'): bool }) +SCHEMA_WEBSOCKET_CLEAR_ITEMS = \ + websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS + }) + @asyncio.coroutine def async_setup(hass, config): @@ -127,6 +133,10 @@ def complete_item_service(call): WS_TYPE_SHOPPING_LIST_UPDATE_ITEM, websocket_handle_update, SCHEMA_WEBSOCKET_UPDATE_ITEM) + hass.components.websocket_api.async_register_command( + WS_TYPE_SHOPPING_LIST_CLEAR_ITEMS, + websocket_handle_clear, + SCHEMA_WEBSOCKET_CLEAR_ITEMS) return True @@ -327,3 +337,11 @@ async def websocket_handle_update(hass, connection, msg): except KeyError: connection.send_message(websocket_api.error_message( msg_id, 'item_not_found', 'Item not found')) + + +@callback +def websocket_handle_clear(hass, connection, msg): + """Handle clearing shopping_list items.""" + hass.data[DOMAIN].async_clear_completed() + hass.bus.async_fire(EVENT) + connection.send_message(websocket_api.result_message(msg['id'])) diff --git a/homeassistant/components/simplisafe/.translations/ru.json b/homeassistant/components/simplisafe/.translations/ru.json index 4ddf405e1eda88..f685297890eae7 100644 --- a/homeassistant/components/simplisafe/.translations/ru.json +++ b/homeassistant/components/simplisafe/.translations/ru.json @@ -11,7 +11,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" }, - "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": "SimpliSafe" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index aaa8e3a19f9790..7f1f8f539eba51 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_UPDATE -REQUIREMENTS = ['simplisafe-python==3.1.13'] +REQUIREMENTS = ['simplisafe-python==3.1.14'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/skybell.py b/homeassistant/components/skybell.py index 3f27c91e7c5b06..b3c3b63bd84a7e 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.2'] +REQUIREMENTS = ['skybellpy==0.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/smhi/.translations/hu.json b/homeassistant/components/smhi/.translations/hu.json index 740fc1a8179c96..86fed8933ef498 100644 --- a/homeassistant/components/smhi/.translations/hu.json +++ b/homeassistant/components/smhi/.translations/hu.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json index 4726d57ad249a7..7811a31ebdb045 100644 --- a/homeassistant/components/sonos/.translations/hu.json +++ b/homeassistant/components/sonos/.translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.", + "single_instance_allowed": "Csak egyetlen Sonos konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges." }, "step": { "confirm": { diff --git a/homeassistant/components/switch/hdmi_cec.py b/homeassistant/components/switch/hdmi_cec.py index b2697b4a2c4efe..1016e91d8d28d1 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -9,7 +9,6 @@ from homeassistant.components.hdmi_cec import CecDevice, ATTR_NEW from homeassistant.components.switch import SwitchDevice, DOMAIN from homeassistant.const import STATE_OFF, STATE_STANDBY, STATE_ON -from homeassistant.core import HomeAssistant DEPENDENCIES = ['hdmi_cec'] @@ -22,20 +21,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Find and return HDMI devices as switches.""" if ATTR_NEW in discovery_info: _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW]) - add_entities(CecSwitchDevice(hass, hass.data.get(device), - hass.data.get(device).logical_address) for - device in discovery_info[ATTR_NEW]) + entities = [] + for device in discovery_info[ATTR_NEW]: + hdmi_device = hass.data.get(device) + entities.append(CecSwitchDevice( + hdmi_device, hdmi_device.logical_address, + )) + add_entities(entities, True) class CecSwitchDevice(CecDevice, SwitchDevice): """Representation of a HDMI device as a Switch.""" - def __init__(self, hass: HomeAssistant, device, logical) -> None: + def __init__(self, device, logical) -> None: """Initialize the HDMI device.""" - CecDevice.__init__(self, hass, device, logical) + CecDevice.__init__(self, device, logical) self.entity_id = "%s.%s_%s" % ( DOMAIN, 'hdmi', hex(self._logical_address)[2:]) - self.update() def turn_on(self, **kwargs) -> None: """Turn device on.""" diff --git a/homeassistant/components/switch/hlk_sw16.py b/homeassistant/components/switch/hlk_sw16.py new file mode 100644 index 00000000000000..d76528c56f06a0 --- /dev/null +++ b/homeassistant/components/switch/hlk_sw16.py @@ -0,0 +1,54 @@ +""" +Support for HLK-SW16 switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.hlk_sw16/ +""" +import logging + +from homeassistant.components.hlk_sw16 import ( + SW16Device, DOMAIN as HLK_SW16, + DATA_DEVICE_REGISTER) +from homeassistant.components.switch import ( + ToggleEntity) +from homeassistant.const import CONF_NAME + +DEPENDENCIES = [HLK_SW16] + +_LOGGER = logging.getLogger(__name__) + + +def devices_from_config(hass, domain_config): + """Parse configuration and add HLK-SW16 switch devices.""" + switches = domain_config[0] + device_id = domain_config[1] + device_client = hass.data[DATA_DEVICE_REGISTER][device_id] + devices = [] + for device_port, device_config in switches.items(): + device_name = device_config.get(CONF_NAME, device_port) + device = SW16Switch(device_name, device_port, device_id, device_client) + devices.append(device) + return devices + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the HLK-SW16 platform.""" + async_add_entities(devices_from_config(hass, discovery_info)) + + +class SW16Switch(SW16Device, ToggleEntity): + """Representation of a HLK-SW16 switch.""" + + @property + def is_on(self): + """Return true if device is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._client.turn_on(self._device_port) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._client.turn_off(self._device_port) diff --git a/homeassistant/components/switch/ihc.py b/homeassistant/components/switch/ihc.py index 4ddafa228a7978..e217d109cbc9e4 100644 --- a/homeassistant/components/switch/ihc.py +++ b/homeassistant/components/switch/ihc.py @@ -3,47 +3,30 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.ihc/ """ -import voluptuous as vol - from homeassistant.components.ihc import ( - validate_name, IHC_DATA, IHC_CONTROLLER, IHC_INFO) + IHC_DATA, IHC_CONTROLLER, IHC_INFO) from homeassistant.components.ihc.ihcdevice import IHCDevice -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.const import CONF_ID, CONF_NAME, CONF_SWITCHES -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['ihc'] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_SWITCHES, default=[]): - vol.All(cv.ensure_list, [ - vol.All({ - vol.Required(CONF_ID): cv.positive_int, - vol.Optional(CONF_NAME): cv.string, - }, validate_name) - ]) -}) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the IHC switch platform.""" - ihc_controller = hass.data[IHC_DATA][IHC_CONTROLLER] - info = hass.data[IHC_DATA][IHC_INFO] + if discovery_info is None: + return devices = [] - if discovery_info: - for name, device in discovery_info.items(): - ihc_id = device['ihc_id'] - product = device['product'] - switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) - devices.append(switch) - else: - switches = config[CONF_SWITCHES] - for switch in switches: - ihc_id = switch[CONF_ID] - name = switch[CONF_NAME] - sensor = IHCSwitch(ihc_controller, name, ihc_id, info) - devices.append(sensor) - + for name, device in discovery_info.items(): + ihc_id = device['ihc_id'] + product = device['product'] + # Find controller that corresponds with device id + ctrl_id = device['ctrl_id'] + ihc_key = IHC_DATA.format(ctrl_id) + info = hass.data[ihc_key][IHC_INFO] + ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + + switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) + devices.append(switch) add_entities(devices) diff --git a/homeassistant/components/switch/lightwave.py b/homeassistant/components/switch/lightwave.py new file mode 100644 index 00000000000000..b612cd8dec74cb --- /dev/null +++ b/homeassistant/components/switch/lightwave.py @@ -0,0 +1,65 @@ +""" +Implements LightwaveRF switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.lightwave/ +""" +from homeassistant.components.lightwave import LIGHTWAVE_LINK +from homeassistant.components.switch import SwitchDevice +from homeassistant.const import CONF_NAME + +DEPENDENCIES = ['lightwave'] + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Find and return LightWave switches.""" + if not discovery_info: + return + + switches = [] + lwlink = hass.data[LIGHTWAVE_LINK] + + for device_id, device_config in discovery_info.items(): + name = device_config[CONF_NAME] + switches.append(LWRFSwitch(name, device_id, lwlink)) + + async_add_entities(switches) + + +class LWRFSwitch(SwitchDevice): + """Representation of a LightWaveRF switch.""" + + def __init__(self, name, device_id, lwlink): + """Initialize LWRFSwitch entity.""" + self._name = name + self._device_id = device_id + self._state = None + self._lwlink = lwlink + + @property + def should_poll(self): + """No polling needed for a LightWave light.""" + return False + + @property + def name(self): + """Lightwave switch name.""" + return self._name + + @property + def is_on(self): + """Lightwave switch is on state.""" + return self._state + + async def async_turn_on(self, **kwargs): + """Turn the LightWave switch on.""" + self._state = True + self._lwlink.turn_on_switch(self._device_id, self._name) + self.async_schedule_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the LightWave switch off.""" + self._state = False + self._lwlink.turn_off(self._device_id, self._name) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index ad2b963629ec87..19e72a9d021e9e 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/switch.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -14,7 +13,7 @@ ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, MqttAvailability, - MqttDiscoveryUpdate, MqttEntityDeviceInfo) + MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription) from homeassistant.components.mqtt.discovery import MQTT_DISCOVERY_NEW from homeassistant.components.switch import SwitchDevice from homeassistant.const import ( @@ -24,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType, ConfigType -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -54,7 +53,7 @@ async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None): """Set up MQTT switch through configuration.yaml.""" - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_info) @@ -63,7 +62,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(hass, config, async_add_entities, + await _async_setup_entity(config, async_add_entities, discovery_payload[ATTR_DISCOVERY_HASH]) async_dispatcher_connect( @@ -71,79 +70,79 @@ async def async_discover(discovery_payload): async_discover) -async def _async_setup_entity(hass, config, async_add_entities, +async def _async_setup_entity(config, async_add_entities, discovery_hash=None): """Set up the MQTT switch.""" - value_template = config.get(CONF_VALUE_TEMPLATE) - if value_template is not None: - value_template.hass = hass - - newswitch = MqttSwitch( - config.get(CONF_NAME), - config.get(CONF_ICON), - config.get(CONF_STATE_TOPIC), - config.get(CONF_COMMAND_TOPIC), - config.get(CONF_AVAILABILITY_TOPIC), - config.get(CONF_QOS), - config.get(CONF_RETAIN), - config.get(CONF_PAYLOAD_ON), - config.get(CONF_PAYLOAD_OFF), - config.get(CONF_STATE_ON), - config.get(CONF_STATE_OFF), - config.get(CONF_OPTIMISTIC), - config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE), - config.get(CONF_UNIQUE_ID), - value_template, - config.get(CONF_DEVICE), - discovery_hash, - ) - - async_add_entities([newswitch]) + async_add_entities([MqttSwitch(config, discovery_hash)]) +# pylint: disable=too-many-ancestors class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, - SwitchDevice): + SwitchDevice, RestoreEntity): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, name, icon, - state_topic, command_topic, availability_topic, - qos, retain, payload_on, payload_off, state_on, - state_off, optimistic, payload_available, - payload_not_available, unique_id: Optional[str], - value_template, device_config: Optional[ConfigType], - discovery_hash): + def __init__(self, config, discovery_hash): """Initialize the MQTT switch.""" + self._state = False + self._sub_state = None + + self._state_on = None + self._state_off = None + self._optimistic = None + self._unique_id = config.get(CONF_UNIQUE_ID) + + # 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) + qos = config.get(CONF_QOS) + device_config = config.get(CONF_DEVICE) + MqttAvailability.__init__(self, availability_topic, qos, payload_available, payload_not_available) - MqttDiscoveryUpdate.__init__(self, discovery_hash) + MqttDiscoveryUpdate.__init__(self, discovery_hash, + self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config) - self._state = False - self._name = name - self._icon = icon - self._state_topic = state_topic - self._command_topic = command_topic - self._qos = qos - self._retain = retain - self._payload_on = payload_on - self._payload_off = payload_off - self._state_on = state_on if state_on else self._payload_on - self._state_off = state_off if state_off else self._payload_off - self._optimistic = optimistic - self._template = value_template - self._unique_id = unique_id - self._discovery_hash = discovery_hash async def async_added_to_hass(self): """Subscribe to MQTT events.""" - await MqttAvailability.async_added_to_hass(self) - await MqttDiscoveryUpdate.async_added_to_hass(self) + await super().async_added_to_hass() + 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._config = config + + state_on = config.get(CONF_STATE_ON) + self._state_on = state_on if state_on else config.get(CONF_PAYLOAD_ON) + + state_off = config.get(CONF_STATE_OFF) + self._state_off = state_off if state_off else \ + config.get(CONF_PAYLOAD_OFF) + + self._optimistic = config.get(CONF_OPTIMISTIC) + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + template = self._config.get(CONF_VALUE_TEMPLATE) + if template is not None: + template.hass = self.hass @callback def state_message_received(topic, payload, qos): """Handle new MQTT state messages.""" - if self._template is not None: - payload = self._template.async_render_with_possible_json_value( + if template is not None: + payload = template.async_render_with_possible_json_value( payload) if payload == self._state_on: self._state = True @@ -152,20 +151,27 @@ def state_message_received(topic, payload, qos): self.async_schedule_update_ha_state() - if self._state_topic is None: + if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. self._optimistic = True else: - 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, + {CONF_STATE_TOPIC: + {'topic': self._config.get(CONF_STATE_TOPIC), + 'msg_callback': state_message_received, + 'qos': self._config.get(CONF_QOS)}}) if self._optimistic: - last_state = await async_get_last_state(self.hass, - self.entity_id) + last_state = await self.async_get_last_state() if last_state: self._state = last_state.state == STATE_ON + 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): """Return the polling state.""" @@ -174,7 +180,7 @@ def should_poll(self): @property def name(self): """Return the name of the switch.""" - return self._name + return self._config.get(CONF_NAME) @property def is_on(self): @@ -194,7 +200,7 @@ def unique_id(self): @property def icon(self): """Return the icon.""" - return self._icon + return self._config.get(CONF_ICON) async def async_turn_on(self, **kwargs): """Turn the device on. @@ -202,8 +208,11 @@ async def async_turn_on(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_on, self._qos, - self._retain) + self.hass, + self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_ON), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that switch has changed state. self._state = True @@ -215,8 +224,11 @@ async def async_turn_off(self, **kwargs): This method is a coroutine. """ mqtt.async_publish( - self.hass, self._command_topic, self._payload_off, self._qos, - self._retain) + self.hass, + self._config.get(CONF_COMMAND_TOPIC), + self._config.get(CONF_PAYLOAD_OFF), + self._config.get(CONF_QOS), + self._config.get(CONF_RETAIN)) if self._optimistic: # Optimistically assume that switch has changed state. self._state = False diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 16dfc075409ef7..3bbe2e69110180 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE, CONF_PROTOCOL, STATE_ON) -from homeassistant.helpers.restore_state import async_get_last_state +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -97,7 +97,7 @@ def run(self, switch, turn_on): switch.set_state(turn_on=turn_on, send_code=self.echo) -class PilightSwitch(SwitchDevice): +class PilightSwitch(SwitchDevice, RestoreEntity): """Representation of a Pilight switch.""" def __init__(self, hass, name, code_on, code_off, code_on_receive, @@ -123,7 +123,8 @@ def __init__(self, hass, name, code_on, code_off, code_on_receive, async def async_added_to_hass(self): """Call when entity about to be added to hass.""" - state = await async_get_last_state(self._hass, self.entity_id) + await super().async_added_to_hass() + state = await self.async_get_last_state() if state: self._state = state.state == STATE_ON diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index 62636b67003b8e..e1da12d317e480 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -14,7 +14,7 @@ CONF_USERNAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.5'] +REQUIREMENTS = ['pysnmp==4.4.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/switchbot.py b/homeassistant/components/switch/switchbot.py index 53f987c8b46c2d..9682a4444aa8fc 100644 --- a/homeassistant/components/switch/switchbot.py +++ b/homeassistant/components/switch/switchbot.py @@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['PySwitchbot==0.3'] +REQUIREMENTS = ['PySwitchbot==0.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/switchmate.py b/homeassistant/components/switch/switchmate.py index e2ca3accdc9cc1..23794abeba49db 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.3'] +REQUIREMENTS = ['pySwitchmate==0.4.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/tellduslive.py b/homeassistant/components/switch/tellduslive.py index 0263dfd8198c97..ed4f825f5ac6b3 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -9,7 +9,8 @@ """ import logging -from homeassistant.components.tellduslive import TelldusLiveEntity +from homeassistant.components import tellduslive +from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) @@ -19,7 +20,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up Tellstick switches.""" if discovery_info is None: return - add_entities(TelldusLiveSwitch(hass, switch) for switch in discovery_info) + client = hass.data[tellduslive.DOMAIN] + add_entities( + TelldusLiveSwitch(client, switch) for switch in discovery_info) class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): @@ -33,9 +36,7 @@ def is_on(self): def turn_on(self, **kwargs): """Turn the switch on.""" self.device.turn_on() - self.changed() def turn_off(self, **kwargs): """Turn the switch off.""" self.device.turn_off() - self.changed() diff --git a/homeassistant/components/switch/volvooncall.py b/homeassistant/components/switch/volvooncall.py index 42c753725abeee..81abf7d0e6cd40 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -8,17 +8,18 @@ """ import logging -from homeassistant.components.volvooncall import VolvoEntity, RESOURCES +from homeassistant.components.volvooncall import VolvoEntity, DATA_KEY from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) -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 a Volvo switch.""" if discovery_info is None: return - add_entities([VolvoSwitch(hass, *discovery_info)]) + async_add_entities([VolvoSwitch(hass.data[DATA_KEY], *discovery_info)]) class VolvoSwitch(VolvoEntity, ToggleEntity): @@ -27,17 +28,12 @@ class VolvoSwitch(VolvoEntity, ToggleEntity): @property def is_on(self): """Return true if switch is on.""" - return self.vehicle.is_heater_on + return self.instrument.state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - self.vehicle.start_heater() + await self.instrument.turn_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the switch off.""" - self.vehicle.stop_heater() - - @property - def icon(self): - """Return the icon.""" - return RESOURCES[self._attribute][2] + await self.instrument.turn_off() diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 7e11f986b9261e..125f89f504027f 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -17,7 +17,7 @@ from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 68a94cc1ca514c..4dac3bfbb22fe1 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -7,7 +7,11 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components import zha +from homeassistant.components.zha import helpers +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) +from homeassistant.components.zha.entities import ZhaEntity +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -16,27 +20,47 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation switches.""" - from zigpy.zcl.clusters.general import OnOff + """Old way of setting up Zigbee Home Automation switches.""" + pass - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - switch = Switch(**discovery_info) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Zigbee Home Automation switch from config entry.""" + async def async_discover(discovery_info): + await _async_setup_entities(hass, config_entry, async_add_entities, + [discovery_info]) - if discovery_info['new_join']: - in_clusters = discovery_info['in_clusters'] - cluster = in_clusters[OnOff.cluster_id] - await zha.configure_reporting( - switch.entity_id, cluster, switch.value_attribute, - min_report=0, max_report=600, reportable_change=1 - ) + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - async_add_entities([switch], update_before_add=True) + switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN) + if switches is not None: + await _async_setup_entities(hass, config_entry, async_add_entities, + switches.values()) + del hass.data[DATA_ZHA][DOMAIN] -class Switch(zha.Entity, SwitchDevice): +async def _async_setup_entities(hass, config_entry, async_add_entities, + discovery_infos): + """Set up the ZHA switches.""" + from zigpy.zcl.clusters.general import OnOff + entities = [] + for discovery_info in discovery_infos: + switch = Switch(**discovery_info) + if discovery_info['new_join']: + in_clusters = discovery_info['in_clusters'] + cluster = in_clusters[OnOff.cluster_id] + await helpers.configure_reporting( + switch.entity_id, cluster, switch.value_attribute, + min_report=0, max_report=600, reportable_change=1 + ) + entities.append(switch) + + async_add_entities(entities, update_before_add=True) + + +class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN @@ -94,8 +118,8 @@ async def async_turn_off(self, **kwargs): async def async_update(self): """Retrieve latest state.""" - result = await zha.safe_read(self._endpoint.on_off, - ['on_off'], - allow_cache=False, - only_cache=(not self._initialized)) + result = await helpers.safe_read(self._endpoint.on_off, + ['on_off'], + allow_cache=False, + only_cache=(not self._initialized)) self._state = result.get('on_off', self._state) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 366799b872c947..645d67b3dc206a 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -36,26 +36,27 @@ ] TAHOMA_TYPES = { - 'rts:RollerShutterRTSComponent': 'cover', - 'rts:CurtainRTSComponent': 'cover', - 'rts:BlindRTSComponent': 'cover', - 'rts:VenetianBlindRTSComponent': 'cover', - 'rts:DualCurtainRTSComponent': 'cover', - 'rts:ExteriorVenetianBlindRTSComponent': 'cover', 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'io:LightIOSystemSensor': 'sensor', + 'io:OnOffLightIOComponent': 'switch', + 'io:RollerShutterGenericIOComponent': 'cover', 'io:RollerShutterUnoIOComponent': 'cover', - 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', 'io:RollerShutterVeluxIOComponent': 'cover', - 'io:RollerShutterGenericIOComponent': 'cover', - 'io:WindowOpenerVeluxIOComponent': 'cover', - 'io:LightIOSystemSensor': 'sensor', - 'rts:GarageDoor4TRTSComponent': 'switch', + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:SomfyContactIOSystemSensor': 'sensor', 'io:VerticalExteriorAwningIOComponent': 'cover', - 'io:HorizontalAwningIOComponent': 'cover', - 'io:OnOffLightIOComponent': 'switch', - 'rtds:RTDSSmokeSensor': 'smoke', + 'io:WindowOpenerVeluxIOComponent': 'cover', 'rtds:RTDSContactSensor': 'sensor', - 'rtds:RTDSMotionSensor': 'sensor' + 'rtds:RTDSMotionSensor': 'sensor', + 'rtds:RTDSSmokeSensor': 'smoke', + 'rts:BlindRTSComponent': 'cover', + 'rts:CurtainRTSComponent': 'cover', + 'rts:DualCurtainRTSComponent': 'cover', + 'rts:ExteriorVenetianBlindRTSComponent': 'cover', + 'rts:GarageDoor4TRTSComponent': 'switch', + 'rts:RollerShutterRTSComponent': 'cover', + 'rts:VenetianBlindRTSComponent': 'cover' } diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive/__init__.py similarity index 68% rename from homeassistant/components/tellduslive.py rename to homeassistant/components/tellduslive/__init__.py index c2b7ba9ba0f533..89e7446448999e 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -4,26 +4,22 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/tellduslive/ """ -from datetime import datetime, timedelta +from datetime import timedelta import logging import voluptuous as vol -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME, - CONF_TOKEN, CONF_HOST, - EVENT_HOMEASSISTANT_START) -from homeassistant.helpers import discovery from homeassistant.components.discovery import SERVICE_TELLDUSLIVE +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.util.dt import utcnow +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -APPLICATION_NAME = 'Home Assistant' +from .const import DOMAIN, SIGNAL_UPDATE_ENTITY -DOMAIN = 'tellduslive' +APPLICATION_NAME = 'Home Assistant' REQUIREMENTS = ['tellduslive==0.10.4'] @@ -49,9 +45,6 @@ }), }, extra=vol.ALLOW_EXTRA) - -ATTR_LAST_UPDATED = 'time_last_updated' - CONFIG_INSTRUCTIONS = """ To link your TelldusLive account: @@ -147,7 +140,7 @@ def tellstick_discovered(service, info): if not supports_local_api(device): _LOGGER.debug('Tellstick does not support local API') # Configure the cloud service - hass.async_add_job(request_configuration) + hass.add_job(request_configuration) return _LOGGER.debug('Tellstick does support local API') @@ -190,18 +183,17 @@ def tellstick_discovered(service, info): return True if not session.is_authorized: - _LOGGER.error( - 'Authentication Error') + _LOGGER.error('Authentication Error') return False client = TelldusLiveClient(hass, config, session) - hass.data[DOMAIN] = client + client.update() - if session: - client.update() - else: - hass.bus.listen(EVENT_HOMEASSISTANT_START, client.update) + interval = config.get(DOMAIN, {}).get(CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL) + _LOGGER.debug('Update interval %s', interval) + track_time_interval(hass, client.update, interval) return True @@ -211,27 +203,15 @@ class TelldusLiveClient: def __init__(self, hass, config, session): """Initialize the Tellus data object.""" - self.entities = [] + self._known_devices = set() self._hass = hass self._config = config - - self._interval = config.get(DOMAIN, {}).get( - CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL) - _LOGGER.debug('Update interval %s', self._interval) self._client = session def update(self, *args): - """Periodically poll the servers for current state.""" - _LOGGER.debug('Updating') - try: - self._sync() - finally: - track_point_in_utc_time( - self._hass, self.update, utcnow() + self._interval) - - def _sync(self): """Update local list of devices.""" + _LOGGER.debug('Updating') if not self._client.update(): _LOGGER.warning('Failed request') @@ -255,9 +235,8 @@ def discover(device_id, component): discovery.load_platform( self._hass, component, DOMAIN, [device_id], self._config) - known_ids = {entity.device_id for entity in self.entities} for device in self._client.devices: - if device.device_id in known_ids: + if device.device_id in self._known_devices: continue if device.is_sensor: for item in device.items: @@ -266,9 +245,9 @@ def discover(device_id, component): else: discover(device.device_id, identify_device(device)) + self._known_devices.add(device.device_id) - for entity in self.entities: - entity.changed() + dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY) def device(self, device_id): """Return device representation.""" @@ -277,86 +256,3 @@ def device(self, device_id): def is_available(self, device_id): """Return device availability.""" return device_id in self._client.device_ids - - -class TelldusLiveEntity(Entity): - """Base class for all Telldus Live entities.""" - - def __init__(self, hass, device_id): - """Initialize the entity.""" - self._id = device_id - self._client = hass.data[DOMAIN] - self._client.entities.append(self) - self._name = self.device.name - _LOGGER.debug('Created device %s', self) - - def changed(self): - """Return the property of the device might have changed.""" - if self.device.name: - self._name = self.device.name - self.schedule_update_ha_state() - - @property - def device_id(self): - """Return the id of the device.""" - return self._id - - @property - def device(self): - """Return the representation of the device.""" - return self._client.device(self.device_id) - - @property - def _state(self): - """Return the state of the device.""" - return self.device.state - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def assumed_state(self): - """Return true if unable to access real state of entity.""" - return True - - @property - def name(self): - """Return name of device.""" - return self._name or DEVICE_DEFAULT_NAME - - @property - def available(self): - """Return true if device is not offline.""" - return self._client.is_available(self.device_id) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attrs = {} - if self._battery_level: - attrs[ATTR_BATTERY_LEVEL] = self._battery_level - if self._last_updated: - attrs[ATTR_LAST_UPDATED] = self._last_updated - return attrs - - @property - def _battery_level(self): - """Return the battery level of a device.""" - from tellduslive import (BATTERY_LOW, - BATTERY_UNKNOWN, - BATTERY_OK) - if self.device.battery == BATTERY_LOW: - return 1 - if self.device.battery == BATTERY_UNKNOWN: - return None - if self.device.battery == BATTERY_OK: - return 100 - return self.device.battery # Percentage - - @property - def _last_updated(self): - """Return the last update of a device.""" - return str(datetime.fromtimestamp(self.device.lastUpdated)) \ - if self.device.lastUpdated else None diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py new file mode 100644 index 00000000000000..a4ef33af5186d6 --- /dev/null +++ b/homeassistant/components/tellduslive/const.py @@ -0,0 +1,5 @@ +"""Consts used by TelldusLive.""" + +DOMAIN = 'tellduslive' + +SIGNAL_UPDATE_ENTITY = 'tellduslive_update' diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py new file mode 100644 index 00000000000000..88b7d47ad9dc27 --- /dev/null +++ b/homeassistant/components/tellduslive/entry.py @@ -0,0 +1,113 @@ +"""Base Entity for all TelldusLiveEntities.""" +from datetime import datetime +import logging + +from homeassistant.const import ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_UPDATE_ENTITY + +_LOGGER = logging.getLogger(__name__) + +ATTR_LAST_UPDATED = 'time_last_updated' + + +class TelldusLiveEntity(Entity): + """Base class for all Telldus Live entities.""" + + def __init__(self, client, device_id): + """Initialize the entity.""" + self._id = device_id + self._client = client + self._name = self.device.name + self._async_unsub_dispatcher_connect = None + + 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) + + 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): + """Return the property of the device might have changed.""" + if self.device.name: + self._name = self.device.name + self.async_schedule_update_ha_state() + + @property + def device_id(self): + """Return the id of the device.""" + return self._id + + @property + def device(self): + """Return the representation of the device.""" + return self._client.device(self.device_id) + + @property + def _state(self): + """Return the state of the device.""" + return self.device.state + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def assumed_state(self): + """Return true if unable to access real state of entity.""" + return True + + @property + def name(self): + """Return name of device.""" + return self._name or DEVICE_DEFAULT_NAME + + @property + def available(self): + """Return true if device is not offline.""" + return self._client.is_available(self.device_id) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attrs = {} + if self._battery_level: + attrs[ATTR_BATTERY_LEVEL] = self._battery_level + if self._last_updated: + attrs[ATTR_LAST_UPDATED] = self._last_updated + return attrs + + @property + def _battery_level(self): + """Return the battery level of a device.""" + from tellduslive import (BATTERY_LOW, + BATTERY_UNKNOWN, + BATTERY_OK) + if self.device.battery == BATTERY_LOW: + return 1 + if self.device.battery == BATTERY_UNKNOWN: + return None + if self.device.battery == BATTERY_OK: + return 100 + return self.device.battery # Percentage + + @property + def _last_updated(self): + """Return the last update of a device.""" + return str(datetime.fromtimestamp(self.device.lastUpdated)) \ + if self.device.lastUpdated else None + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._id diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 2545417e0335b9..8462b646a22d5d 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.2'] +REQUIREMENTS = ['pyTibber==0.8.6'] DOMAIN = 'tibber' @@ -45,7 +45,11 @@ async def _close(event): try: await tibber_connection.update_info() - except (asyncio.TimeoutError, aiohttp.ClientError): + except asyncio.TimeoutError as err: + _LOGGER.error("Timeout connecting to Tibber: %s ", err) + return False + except aiohttp.ClientError as err: + _LOGGER.error("Error connecting to Tibber: %s ", err) return False except tibber.InvalidLogin as exp: _LOGGER.error("Failed to login. %s", exp) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index c29df9db8588cb..3f758edea863c2 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -12,9 +12,9 @@ import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.restore_state import RestoreEntity _LOGGER = logging.getLogger(__name__) @@ -97,7 +97,7 @@ async def async_setup(hass, config): return True -class Timer(Entity): +class Timer(RestoreEntity): """Representation of a timer.""" def __init__(self, hass, object_id, name, icon, duration): @@ -146,8 +146,7 @@ async def async_added_to_hass(self): if self._state is not None: return - restore_state = self._hass.helpers.restore_state - state = await restore_state.async_get_last_state(self.entity_id) + state = await self.async_get_last_state() self._state = state and state.state == state async def async_start(self, duration): diff --git a/homeassistant/components/tradfri/.translations/cs.json b/homeassistant/components/tradfri/.translations/cs.json index 97a0e25d754166..58782a1b42118e 100644 --- a/homeassistant/components/tradfri/.translations/cs.json +++ b/homeassistant/components/tradfri/.translations/cs.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Nelze se p\u0159ipojit k br\u00e1n\u011b.", + "invalid_key": "Nepoda\u0159ilo se zaregistrovat pomoc\u00ed zadan\u00e9ho kl\u00ed\u010de. Pokud se situace opakuje, zkuste restartovat gateway.", "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el" }, "step": { diff --git a/homeassistant/components/tradfri/.translations/ru.json b/homeassistant/components/tradfri/.translations/ru.json index c7fcfd50b56ca5..c42ca6b7b2b3f8 100644 --- a/homeassistant/components/tradfri/.translations/ru.json +++ b/homeassistant/components/tradfri/.translations/ru.json @@ -4,7 +4,7 @@ "already_configured": "\u0428\u043b\u044e\u0437 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d" }, "error": { - "cannot_connect": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443", "invalid_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043a\u043b\u044e\u0447\u043e\u043c. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0448\u043b\u044e\u0437.", "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430." }, diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 7b3fe4ef04e8cd..e3f5b7407cdf29 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -10,9 +10,10 @@ from homeassistant.components.tts import Provider, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) REQUIREMENTS = ['boto3==1.9.16'] +_LOGGER = logging.getLogger(__name__) + CONF_REGION = 'region_name' CONF_ACCESS_KEY_ID = 'aws_access_key_id' CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key' @@ -31,15 +32,37 @@ CONF_SAMPLE_RATE = 'sample_rate' CONF_TEXT_TYPE = 'text_type' -SUPPORTED_VOICES = ['Geraint', 'Gwyneth', 'Mads', 'Naja', 'Hans', 'Marlene', - 'Nicole', 'Russell', 'Amy', 'Brian', 'Emma', 'Raveena', - 'Ivy', 'Joanna', 'Joey', 'Justin', 'Kendra', 'Kimberly', - 'Salli', 'Conchita', 'Enrique', 'Miguel', 'Penelope', - 'Chantal', 'Celine', 'Mathieu', 'Dora', 'Karl', 'Carla', - 'Giorgio', 'Mizuki', 'Liv', 'Lotte', 'Ruben', 'Ewa', - 'Jacek', 'Jan', 'Maja', 'Ricardo', 'Vitoria', 'Cristiano', - 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz', - 'Aditi', 'Léa', 'Matthew', 'Seoyeon', 'Takumi', 'Vicki'] +SUPPORTED_VOICES = [ + 'Zhiyu', # Chinese + 'Mads', 'Naja', # Danish + 'Ruben', 'Lotte', # Dutch + 'Russell', 'Nicole', # English Austrailian + 'Brian', 'Amy', 'Emma', # English + 'Aditi', 'Raveena', # English, Indian + 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly', + 'Salli', # English + 'Geraint', # English Welsh + 'Mathieu', 'Celine', 'Léa', # French + 'Chantal', # French Canadian + 'Hans', 'Marlene', 'Vicki', # German + 'Aditi', # Hindi + 'Karl', 'Dora', # Icelandic + 'Giorgio', 'Carla', 'Bianca', # Italian + 'Takumi', 'Mizuki', # Japanese + 'Seoyeon', # Korean + 'Liv', # Norwegian + 'Jacek', 'Jan', 'Ewa', 'Maja', # Polish + 'Ricardo', 'Vitoria', # Portuguese, Brazilian + 'Cristiano', 'Ines', # Portuguese, European + 'Carmen', # Romanian + 'Maxim', 'Tatyana', # Russian + 'Enrique', 'Conchita', 'Lucia' # Spanish European + 'Mia', # Spanish Mexican + 'Miguel', 'Penelope', # Spanish US + 'Astrid', # Swedish + 'Filiz', # Turkish + 'Gwyneth', # Welsh +] SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm'] @@ -48,7 +71,7 @@ SUPPORTED_SAMPLE_RATES_MAP = { 'mp3': ['8000', '16000', '22050'], 'ogg_vorbis': ['8000', '16000', '22050'], - 'pcm': ['8000', '16000'] + 'pcm': ['8000', '16000'], } SUPPORTED_TEXT_TYPES = ['text', 'ssml'] @@ -56,7 +79,7 @@ CONTENT_TYPE_EXTENSIONS = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', - 'audio/pcm': 'pcm' + 'audio/pcm': 'pcm', } DEFAULT_VOICE = 'Joanna' @@ -66,7 +89,7 @@ DEFAULT_SAMPLE_RATES = { 'mp3': '22050', 'ogg_vorbis': '22050', - 'pcm': '16000' + 'pcm': '16000', } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -78,8 +101,8 @@ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES), vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT): vol.In(SUPPORTED_OUTPUT_FORMATS), - vol.Optional(CONF_SAMPLE_RATE): vol.All(cv.string, - vol.In(SUPPORTED_SAMPLE_RATES)), + vol.Optional(CONF_SAMPLE_RATE): + vol.All(cv.string, vol.In(SUPPORTED_SAMPLE_RATES)), vol.Optional(CONF_TEXT_TYPE, default=DEFAULT_TEXT_TYPE): vol.In(SUPPORTED_TEXT_TYPES), }) @@ -88,8 +111,8 @@ def get_engine(hass, config): """Set up Amazon Polly speech component.""" output_format = config.get(CONF_OUTPUT_FORMAT) - sample_rate = config.get(CONF_SAMPLE_RATE, - DEFAULT_SAMPLE_RATES[output_format]) + sample_rate = config.get( + CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format]) if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format): _LOGGER.error("%s is not a valid sample rate for %s", sample_rate, output_format) @@ -127,8 +150,8 @@ def get_engine(hass, config): if voice.get('LanguageCode') not in supported_languages: supported_languages.append(voice.get('LanguageCode')) - return AmazonPollyProvider(polly_client, config, supported_languages, - all_voices) + return AmazonPollyProvider( + polly_client, config, supported_languages, all_voices) class AmazonPollyProvider(Provider): @@ -171,7 +194,7 @@ def get_tts_audio(self, message, language=None, options=None): if language != voice_in_dict.get('LanguageCode'): _LOGGER.error("%s does not support the %s language", voice_id, language) - return (None, None) + return None, None resp = self.client.synthesize_speech( OutputFormat=self.config[CONF_OUTPUT_FORMAT], diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json index 6f63614fdb7458..6f9e22bfd4030f 100644 --- a/homeassistant/components/twilio/.translations/ca.json +++ b/homeassistant/components/twilio/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia." }, "create_entry": { - "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio]({twilio_url}).\n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nConsulteu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." + "default": "Per enviar esdeveniments a Home Assistant, haureu de configurar [Webhooks amb Twilio]({twilio_url}).\n\n Ompliu la informaci\u00f3 seg\u00fcent: \n\n - URL: `{webhook_url}` \n - M\u00e8tode: POST \n - Tipus de contingut: application/x-www-form-urlencoded\n\nVegeu [la documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar les dades entrants." }, "step": { "user": { diff --git a/homeassistant/components/twilio/.translations/ko.json b/homeassistant/components/twilio/.translations/ko.json index 028919bff9056c..8790c70800883d 100644 --- a/homeassistant/components/twilio/.translations/ko.json +++ b/homeassistant/components/twilio/.translations/ko.json @@ -5,11 +5,11 @@ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." }, "create_entry": { - "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \n Home Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574 \uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574 \uc8fc\uc138\uc694." }, "step": { "user": { - "description": "Twilio \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "description": "Twilio \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", "title": "Twilio Webhook \uc124\uc815" } }, diff --git a/homeassistant/components/twilio/.translations/ru.json b/homeassistant/components/twilio/.translations/ru.json index e758a47064e157..c195392be2233b 100644 --- a/homeassistant/components/twilio/.translations/ru.json +++ b/homeassistant/components/twilio/.translations/ru.json @@ -10,7 +10,7 @@ "step": { "user": { "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 Twilio?", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Twilio Webhook" + "title": "Twilio Webhook" } }, "title": "Twilio" diff --git a/homeassistant/components/unifi/.translations/cs.json b/homeassistant/components/unifi/.translations/cs.json index 95ba46597da6a3..3ea631ec86ccdf 100644 --- a/homeassistant/components/unifi/.translations/cs.json +++ b/homeassistant/components/unifi/.translations/cs.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u0158adi\u010d je ji\u017e nakonfigurov\u00e1n", "user_privilege": "U\u017eivatel mus\u00ed b\u00fdt spr\u00e1vcem" }, "error": { diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index 908c1c5d0c579c..ca1a802a580c64 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -17,7 +17,7 @@ "site": "ID \u0441\u0430\u0439\u0442\u0430", "username": "\u0418\u043c\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f" }, - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 UniFi Controller" + "title": "UniFi Controller" } }, "title": "UniFi Controller" diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json index a2bf78a7f3e04c..fc0225cc534fa7 100644 --- a/homeassistant/components/upnp/.translations/hu.json +++ b/homeassistant/components/upnp/.translations/hu.json @@ -6,6 +6,7 @@ }, "user": { "data": { + "enable_sensors": "Forgalom \u00e9rz\u00e9kel\u0151k hozz\u00e1ad\u00e1sa", "igd": "UPnP/IGD" }, "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei" diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json index 5cb9a3f4a27685..8e86c41366bf1e 100644 --- a/homeassistant/components/upnp/.translations/ru.json +++ b/homeassistant/components/upnp/.translations/ru.json @@ -16,7 +16,7 @@ "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", "igd": "UPnP / IGD" }, - "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f UPnP / IGD" + "title": "UPnP / IGD" } }, "title": "UPnP / IGD" diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index a491b69ca2f671..943b487857fbbf 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.4.3', 'construct==2.9.45'] +REQUIREMENTS = ['python-miio==0.4.4', 'construct==2.9.45'] _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,8 @@ 'Turbo': 77, 'Max': 90} +ATTR_CLEAN_START = 'clean_start' +ATTR_CLEAN_STOP = 'clean_stop' ATTR_CLEANING_TIME = 'cleaning_time' ATTR_DO_NOT_DISTURB = 'do_not_disturb' ATTR_DO_NOT_DISTURB_START = 'do_not_disturb_start' @@ -169,6 +171,7 @@ def __init__(self, name, vacuum): self.consumable_state = None self.clean_history = None self.dnd_state = None + self.last_clean = None @property def name(self): @@ -248,6 +251,10 @@ def device_state_attributes(self): ATTR_STATUS: str(self.vacuum_state.state) }) + if self.last_clean: + attrs[ATTR_CLEAN_START] = self.last_clean.start + attrs[ATTR_CLEAN_STOP] = self.last_clean.end + if self.vacuum_state.got_error: attrs[ATTR_ERROR] = self.vacuum_state.error return attrs @@ -368,6 +375,7 @@ def update(self): self.consumable_state = self._vacuum.consumable_status() self.clean_history = self._vacuum.clean_history() + self.last_clean = self._vacuum.last_clean_details() self.dnd_state = self._vacuum.dnd_status() self._available = True diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index 2f2fa194846843..481aa331e41f3f 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -28,6 +28,7 @@ CONF_GIID = 'giid' CONF_HYDROMETERS = 'hygrometers' CONF_LOCKS = 'locks' +CONF_DEFAULT_LOCK_CODE = 'default_lock_code' CONF_MOUSE = 'mouse' CONF_SMARTPLUGS = 'smartplugs' CONF_THERMOMETERS = 'thermometers' @@ -52,6 +53,7 @@ vol.Optional(CONF_GIID): cv.string, vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean, vol.Optional(CONF_LOCKS, default=True): cv.boolean, + vol.Optional(CONF_DEFAULT_LOCK_CODE): cv.string, vol.Optional(CONF_MOUSE, default=True): cv.boolean, vol.Optional(CONF_SMARTPLUGS, default=True): cv.boolean, vol.Optional(CONF_THERMOMETERS, default=True): cv.boolean, diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 0ce8870bedfa05..75339171cbc4cb 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -14,15 +14,17 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_point_in_utc_time -from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, + async_dispatcher_connect) from homeassistant.util.dt import utcnow DOMAIN = 'volvooncall' DATA_KEY = DOMAIN -REQUIREMENTS = ['volvooncall==0.4.0'] +REQUIREMENTS = ['volvooncall==0.7.11'] _LOGGER = logging.getLogger(__name__) @@ -33,25 +35,56 @@ CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' - -SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) - -RESOURCES = {'position': ('device_tracker',), - 'lock': ('lock', 'Lock'), - 'heater': ('switch', 'Heater', 'mdi:radiator'), - 'odometer': ('sensor', 'Odometer', 'mdi:speedometer', 'km'), - 'fuel_amount': ('sensor', 'Fuel amount', 'mdi:gas-station', 'L'), - 'fuel_amount_level': ( - 'sensor', 'Fuel level', 'mdi:water-percent', '%'), - 'average_fuel_consumption': ( - 'sensor', 'Fuel consumption', 'mdi:gas-station', 'L/100 km'), - 'distance_to_empty': ('sensor', 'Range', 'mdi:ruler', 'km'), - 'washer_fluid_level': ('binary_sensor', 'Washer fluid'), - 'brake_fluid': ('binary_sensor', 'Brake Fluid'), - 'service_warning_status': ('binary_sensor', 'Service'), - 'bulb_failures': ('binary_sensor', 'Bulbs'), - 'doors': ('binary_sensor', 'Doors'), - 'windows': ('binary_sensor', 'Windows')} +CONF_MUTABLE = 'mutable' + +SIGNAL_STATE_UPDATED = '{}.updated'.format(DOMAIN) + +COMPONENTS = { + 'sensor': 'sensor', + 'binary_sensor': 'binary_sensor', + 'lock': 'lock', + 'device_tracker': 'device_tracker', + 'switch': 'switch' +} + +RESOURCES = [ + 'position', + 'lock', + 'heater', + 'odometer', + 'trip_meter1', + 'trip_meter2', + 'fuel_amount', + 'fuel_amount_level', + 'average_fuel_consumption', + 'distance_to_empty', + 'washer_fluid_level', + 'brake_fluid', + 'service_warning_status', + 'bulb_failures', + 'battery_range', + 'battery_level', + 'time_to_fully_charged', + 'battery_charge_status', + 'engine_start', + 'last_trip', + 'is_engine_running', + 'doors_hood_open', + 'doors_front_left_door_open', + 'doors_front_right_door_open', + 'doors_rear_left_door_open', + 'doors_rear_right_door_open', + 'windows_front_left_window_open', + 'windows_front_right_window_open', + 'windows_rear_left_window_open', + 'windows_rear_right_window_open', + 'tyre_pressure_front_left_tyre_pressure', + 'tyre_pressure_front_right_tyre_pressure', + 'tyre_pressure_rear_left_tyre_pressure', + 'tyre_pressure_rear_right_tyre_pressure', + 'any_door_open', + 'any_window_open' +] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -65,12 +98,13 @@ cv.ensure_list, [vol.In(RESOURCES)]), vol.Optional(CONF_REGION): cv.string, vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_MUTABLE, default=True): cv.boolean, vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, }), }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Volvo On Call component.""" from volvooncall import Connection connection = Connection( @@ -81,44 +115,57 @@ def setup(hass, config): interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) - state = hass.data[DATA_KEY] = VolvoData(config) + data = hass.data[DATA_KEY] = VolvoData(config) + + def is_enabled(attr): + """Return true if the user has enabled the resource.""" + return attr in config[DOMAIN].get(CONF_RESOURCES, [attr]) def discover_vehicle(vehicle): """Load relevant platforms.""" - state.entities[vehicle.vin] = [] - for attr, (component, *_) in RESOURCES.items(): - if (getattr(vehicle, attr + '_supported', True) and - attr in config[DOMAIN].get(CONF_RESOURCES, [attr])): - discovery.load_platform( - hass, component, DOMAIN, (vehicle.vin, attr), config) - - def update_vehicle(vehicle): - """Receive updated information on vehicle.""" - state.vehicles[vehicle.vin] = vehicle - if vehicle.vin not in state.entities: - discover_vehicle(vehicle) - - for entity in state.entities[vehicle.vin]: - entity.schedule_update_ha_state() - - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) - - def update(now): + data.vehicles.add(vehicle.vin) + + dashboard = vehicle.dashboard( + mutable=config[DOMAIN][CONF_MUTABLE], + scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES]) + + for instrument in ( + instrument + for instrument in dashboard.instruments + if instrument.component in COMPONENTS and + is_enabled(instrument.slug_attr)): + + data.instruments.add(instrument) + + hass.async_create_task( + discovery.async_load_platform( + hass, + COMPONENTS[instrument.component], + DOMAIN, + (vehicle.vin, + instrument.component, + instrument.attr), + config)) + + async def update(now): """Update status from the online service.""" try: - if not connection.update(): + if not await connection.update(journal=True): _LOGGER.warning("Could not query server") return False for vehicle in connection.vehicles: - update_vehicle(vehicle) + if vehicle.vin not in data.vehicles: + discover_vehicle(vehicle) + + async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) return True finally: - track_point_in_utc_time(hass, update, utcnow() + interval) + async_track_point_in_utc_time(hass, update, utcnow() + interval) _LOGGER.info("Logging in to service") - return update(utcnow()) + return await update(utcnow()) class VolvoData: @@ -126,11 +173,19 @@ class VolvoData: def __init__(self, config): """Initialize the component state.""" - self.entities = {} - self.vehicles = {} + self.vehicles = set() + self.instruments = set() self.config = config[DOMAIN] self.names = self.config.get(CONF_NAME) + def instrument(self, vin, component, attr): + """Return corresponding instrument.""" + return next((instrument + for instrument in self.instruments + if instrument.vehicle.vin == vin and + instrument.component == component and + instrument.attr == attr), None) + def vehicle_name(self, vehicle): """Provide a friendly name for a vehicle.""" if (vehicle.registration_number and @@ -148,29 +203,41 @@ def vehicle_name(self, vehicle): class VolvoEntity(Entity): """Base class for all VOC entities.""" - def __init__(self, hass, vin, attribute): + def __init__(self, data, vin, component, attribute): """Initialize the entity.""" - self._hass = hass - self._vin = vin - self._attribute = attribute - self._state.entities[self._vin].append(self) + self.data = data + self.vin = vin + self.component = component + self.attribute = attribute + + async def async_added_to_hass(self): + """Register update dispatcher.""" + async_dispatcher_connect( + self.hass, SIGNAL_STATE_UPDATED, + self.async_schedule_update_ha_state) + + @property + def instrument(self): + """Return corresponding instrument.""" + return self.data.instrument(self.vin, self.component, self.attribute) @property - def _state(self): - return self._hass.data[DATA_KEY] + def icon(self): + """Return the icon.""" + return self.instrument.icon @property def vehicle(self): """Return vehicle.""" - return self._state.vehicles[self._vin] + return self.instrument.vehicle @property def _entity_name(self): - return RESOURCES[self._attribute][1] + return self.instrument.name @property def _vehicle_name(self): - return self._state.vehicle_name(self.vehicle) + return self.data.vehicle_name(self.vehicle) @property def name(self): @@ -192,6 +259,7 @@ def assumed_state(self): @property def device_state_attributes(self): """Return device specific state attributes.""" - return dict(model='{}/{}'.format( - self.vehicle.vehicle_type, - self.vehicle.model_year)) + return dict(self.instrument.attributes, + model='{}/{}'.format( + self.vehicle.vehicle_type, + self.vehicle.model_year)) diff --git a/homeassistant/components/waterfurnace.py b/homeassistant/components/waterfurnace.py index e9024131af84de..0947afea1414e3 100644 --- a/homeassistant/components/waterfurnace.py +++ b/homeassistant/components/waterfurnace.py @@ -19,13 +19,12 @@ from homeassistant.helpers import discovery -REQUIREMENTS = ["waterfurnace==0.7.0"] +REQUIREMENTS = ["waterfurnace==1.0.0"] _LOGGER = logging.getLogger(__name__) DOMAIN = "waterfurnace" UPDATE_TOPIC = DOMAIN + "_update" -CONF_UNIT = "unit" SCAN_INTERVAL = timedelta(seconds=10) ERROR_INTERVAL = timedelta(seconds=300) MAX_FAILS = 10 @@ -36,8 +35,7 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_UNIT): cv.string, + vol.Required(CONF_USERNAME): cv.string }), }, extra=vol.ALLOW_EXTRA) @@ -49,9 +47,8 @@ def setup(hass, base_config): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - unit = config.get(CONF_UNIT) - wfconn = wf.WaterFurnace(username, password, unit) + wfconn = wf.WaterFurnace(username, password) # NOTE(sdague): login will throw an exception if this doesn't # work, which will abort the setup. try: @@ -83,7 +80,7 @@ def __init__(self, hass, client): super().__init__() self.hass = hass self.client = client - self.unit = client.unit + self.unit = self.client.gwid self.data = None self._shutdown = False self._fails = 0 diff --git a/homeassistant/components/weather/bom.py b/homeassistant/components/weather/bom.py index 4c517824bca01a..1ed54496c6f53a 100644 --- a/homeassistant/components/weather/bom.py +++ b/homeassistant/components/weather/bom.py @@ -33,7 +33,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if station is None: _LOGGER.error("Could not get BOM weather station from lat/lon") return False - bom_data = BOMCurrentData(hass, station) + bom_data = BOMCurrentData(station) try: bom_data.update() except ValueError as err: diff --git a/homeassistant/components/weather/ipma.py b/homeassistant/components/weather/ipma.py index 7fecfbcd0744b6..55a1527db8c5cc 100644 --- a/homeassistant/components/weather/ipma.py +++ b/homeassistant/components/weather/ipma.py @@ -20,7 +20,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyipma==1.1.4'] +REQUIREMENTS = ['pyipma==1.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/webhook.py b/homeassistant/components/webhook.py index ad23ba6f544e79..6742f33c72dc5a 100644 --- a/homeassistant/components/webhook.py +++ b/homeassistant/components/webhook.py @@ -62,6 +62,28 @@ def async_generate_url(hass, webhook_id): return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id) +@bind_hass +async def async_handle_webhook(hass, webhook_id, request): + """Handle a webhook.""" + handlers = hass.data.setdefault(DOMAIN, {}) + webhook = handlers.get(webhook_id) + + # Always respond successfully to not give away if a hook exists or not. + if webhook is None: + _LOGGER.warning( + 'Received message for unregistered webhook %s', webhook_id) + return Response(status=200) + + try: + 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) + + async def async_setup(hass, config): """Initialize the webhook component.""" hass.http.register_view(WebhookView) @@ -82,23 +104,7 @@ class WebhookView(HomeAssistantView): async def post(self, request, webhook_id): """Handle webhook call.""" hass = request.app['hass'] - handlers = hass.data.setdefault(DOMAIN, {}) - webhook = handlers.get(webhook_id) - - # Always respond successfully to not give away if a hook exists or not. - if webhook is None: - _LOGGER.warning( - 'Received message for unregistered webhook %s', webhook_id) - return Response(status=200) - - try: - 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) + return await async_handle_webhook(hass, webhook_id, request) @callback diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index db41f3df06d27c..434775c9b9be84 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -69,7 +69,7 @@ async def async_handle(self, msg): self._send_message(auth_invalid_message(error_msg)) raise Disconnect - if self._hass.auth.active and 'access_token' in msg: + if 'access_token' in msg: self._logger.debug("Received access_token") refresh_token = \ await self._hass.auth.async_validate_access_token( @@ -78,8 +78,7 @@ async def async_handle(self, msg): return await self._async_finish_auth( refresh_token.user, refresh_token) - elif ((not self._hass.auth.active or self._hass.auth.support_legacy) - and 'api_password' in msg): + elif self._hass.auth.support_legacy and 'api_password' in msg: self._logger.debug("Received api_password") if validate_password(self._request, msg['api_password']): return await self._async_finish_auth(None, None) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 771a6a57f4fa66..ff928b43873980 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,6 +3,7 @@ from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED from homeassistant.core import callback, DOMAIN as HASS_DOMAIN +from homeassistant.exceptions import Unauthorized, ServiceNotFound from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_get_all_descriptions @@ -98,6 +99,9 @@ def handle_subscribe_events(hass, connection, msg): Async friendly. """ + if not connection.user.is_admin: + raise Unauthorized + async def forward_events(event): """Forward events to websocket.""" if event.event_type == EVENT_TIME_CHANGED: @@ -137,10 +141,15 @@ async def handle_call_service(hass, connection, msg): if (msg['domain'] == HASS_DOMAIN and msg['service'] in ['restart', 'stop']): blocking = False - await hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), blocking, - connection.context(msg)) - connection.send_message(messages.result_message(msg['id'])) + + try: + await hass.services.async_call( + msg['domain'], msg['service'], msg.get('service_data'), blocking, + connection.context(msg)) + connection.send_message(messages.result_message(msg['id'])) + except ServiceNotFound: + connection.send_message(messages.error_message( + msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) @callback @@ -149,8 +158,14 @@ def handle_get_states(hass, connection, msg): Async friendly. """ + entity_perm = connection.user.permissions.check_entity + states = [ + state for state in hass.states.async_all() + if entity_perm(state.entity_id, 'read') + ] + connection.send_message(messages.result_message( - msg['id'], hass.states.async_all())) + msg['id'], states)) @decorators.async_response diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 1cb58591a0af5a..60e2caa54acd53 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -2,6 +2,7 @@ import voluptuous as vol from homeassistant.core import callback, Context +from homeassistant.exceptions import Unauthorized from . import const, messages @@ -63,11 +64,8 @@ def async_handle(self, msg): try: handler(self.hass, self, schema(msg)) - except Exception: # pylint: disable=broad-except - self.logger.exception('Error handling message: %s', msg) - self.send_message(messages.error_message( - cur_id, const.ERR_UNKNOWN_ERROR, - 'Unknown error.')) + except Exception as err: # pylint: disable=broad-except + self.async_handle_exception(msg, err) self.last_id = cur_id @@ -76,3 +74,20 @@ def async_close(self): """Close down connection.""" for unsub in self.event_listeners.values(): unsub() + + @callback + def async_handle_exception(self, msg, err): + """Handle an exception while processing a handler.""" + if isinstance(err, Unauthorized): + code = const.ERR_UNAUTHORIZED + err_message = 'Unauthorized' + elif isinstance(err, vol.Invalid): + code = const.ERR_INVALID_FORMAT + err_message = 'Invalid format' + else: + self.logger.exception('Error handling message: %s', msg) + code = const.ERR_UNKNOWN_ERROR + err_message = 'Unknown error' + + self.send_message( + messages.error_message(msg['id'], code, err_message)) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 8d452959ca52b4..fd8f7eb7b08a0f 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -6,11 +6,12 @@ URL = '/api/websocket' MAX_PENDING_MSG = 512 -ERR_ID_REUSE = 1 -ERR_INVALID_FORMAT = 2 -ERR_NOT_FOUND = 3 -ERR_UNKNOWN_COMMAND = 4 -ERR_UNKNOWN_ERROR = 5 +ERR_ID_REUSE = 'id_reuse' +ERR_INVALID_FORMAT = 'invalid_format' +ERR_NOT_FOUND = 'not_found' +ERR_UNKNOWN_COMMAND = 'unknown_command' +ERR_UNKNOWN_ERROR = 'unknown_error' +ERR_UNAUTHORIZED = 'unauthorized' TYPE_RESULT = 'result' diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py index 5f78790f5db3f9..34250202a5e8b2 100644 --- a/homeassistant/components/websocket_api/decorators.py +++ b/homeassistant/components/websocket_api/decorators.py @@ -14,10 +14,8 @@ async def _handle_async_response(func, hass, connection, msg): """Create a response and handle exception.""" try: await func(hass, connection, msg) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - connection.send_message(messages.error_message( - msg['id'], 'unknown', 'Unexpected error occurred')) + except Exception as err: # pylint: disable=broad-except + connection.async_handle_exception(msg, err) def async_response(func): diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 13be503a0095ae..42c2c0a5751e3b 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -13,11 +13,12 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.helpers.json import JSONEncoder -from .const import MAX_PENDING_MSG, CANCELLATION_ERRORS, URL +from .const import MAX_PENDING_MSG, CANCELLATION_ERRORS, URL, ERR_UNKNOWN_ERROR from .auth import AuthPhase, auth_required_message from .error import Disconnect +from .messages import error_message -JSON_DUMP = partial(json.dumps, cls=JSONEncoder) +JSON_DUMP = partial(json.dumps, cls=JSONEncoder, allow_nan=False) class WebsocketAPIView(HomeAssistantView): @@ -58,9 +59,12 @@ async def _writer(self): self._logger.debug("Sending %s", message) try: await self.wsock.send_json(message, dumps=JSON_DUMP) - except TypeError as err: + except (ValueError, TypeError) as err: self._logger.error('Unable to serialize to JSON: %s\n%s', err, message) + await self.wsock.send_json(error_message( + message['id'], ERR_UNKNOWN_ERROR, + 'Invalid JSON in response')) @callback def _send_message(self, message): diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 93760405e08051..1d0133739c350f 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.29'] +REQUIREMENTS = ['pywemo==0.4.33'] DOMAIN = 'wemo' diff --git a/homeassistant/components/wunderlist/__init__.py b/homeassistant/components/wunderlist/__init__.py new file mode 100644 index 00000000000000..f64d97dfc0d5c3 --- /dev/null +++ b/homeassistant/components/wunderlist/__init__.py @@ -0,0 +1,91 @@ +""" +Component to interact with Wunderlist. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/wunderlist/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ( + CONF_NAME, CONF_ACCESS_TOKEN) + +REQUIREMENTS = ['wunderpy2==0.1.6'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'wunderlist' +CONF_CLIENT_ID = 'client_id' +CONF_LIST_NAME = 'list_name' +CONF_STARRED = 'starred' + + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_ACCESS_TOKEN): cv.string + }) +}, extra=vol.ALLOW_EXTRA) + + +SERVICE_CREATE_TASK = 'create_task' + +SERVICE_SCHEMA_CREATE_TASK = vol.Schema({ + vol.Required(CONF_LIST_NAME): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_STARRED): cv.boolean +}) + + +def setup(hass, config): + """Set up the Wunderlist component.""" + conf = config[DOMAIN] + client_id = conf.get(CONF_CLIENT_ID) + access_token = conf.get(CONF_ACCESS_TOKEN) + data = Wunderlist(access_token, client_id) + if not data.check_credentials(): + _LOGGER.error("Invalid credentials") + return False + + hass.services.register(DOMAIN, 'create_task', data.create_task) + return True + + +class Wunderlist: + """Representation of an interface to Wunderlist.""" + + def __init__(self, access_token, client_id): + """Create new instance of Wunderlist component.""" + import wunderpy2 + + api = wunderpy2.WunderApi() + self._client = api.get_client(access_token, client_id) + + _LOGGER.debug("Instance created") + + def check_credentials(self): + """Check if the provided credentials are valid.""" + try: + self._client.get_lists() + return True + except ValueError: + return False + + def create_task(self, call): + """Create a new task on a list of Wunderlist.""" + list_name = call.data.get(CONF_LIST_NAME) + task_title = call.data.get(CONF_NAME) + starred = call.data.get(CONF_STARRED) + list_id = self._list_by_name(list_name) + self._client.create_task(list_id, task_title, starred=starred) + return True + + def _list_by_name(self, name): + """Return a list ID by name.""" + lists = self._client.get_lists() + tmp = [l for l in lists if l["title"] == name] + if tmp: + return tmp[0]["id"] + return None diff --git a/homeassistant/components/wunderlist/services.yaml b/homeassistant/components/wunderlist/services.yaml new file mode 100644 index 00000000000000..a3b097c5d35368 --- /dev/null +++ b/homeassistant/components/wunderlist/services.yaml @@ -0,0 +1,15 @@ +# Describes the format for available Wunderlist + +create_task: + description: > + Create a new task in Wunderlist. + fields: + list_name: + description: name of the new list where the task will be created + example: 'Shopping list' + name: + description: name of the new task + example: 'Buy 5 bottles of beer' + starred: + description: Create the task as starred [Optional] + example: true diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json new file mode 100644 index 00000000000000..1feac454c454f9 --- /dev/null +++ b/homeassistant/components/zha/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de ZHA." + }, + "error": { + "cannot_connect": "No es pot connectar amb el dispositiu ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Tipus de r\u00e0dio", + "usb_path": "Ruta del port USB amb el dispositiu" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json new file mode 100644 index 00000000000000..f0da251f5eb643 --- /dev/null +++ b/homeassistant/components/zha/.translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + }, + "step": { + "user": { + "data": { + "radio_type": "Radio Type", + "usb_path": "USB Device Path" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ko.json b/homeassistant/components/zha/.translations/ko.json new file mode 100644 index 00000000000000..ffeaf4588e6808 --- /dev/null +++ b/homeassistant/components/zha/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\ud558\ub098\uc758 ZHA \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "cannot_connect": "ZHA \uc7a5\uce58\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "radio_type": "\ubb34\uc120 \uc720\ud615", + "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c" + }, + "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/lb.json b/homeassistant/components/zha/.translations/lb.json new file mode 100644 index 00000000000000..37304c8c8fda8e --- /dev/null +++ b/homeassistant/components/zha/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt." + }, + "error": { + "cannot_connect": "Keng Verbindung mam ZHA Apparat m\u00e9iglech." + }, + "step": { + "user": { + "data": { + "radio_type": "Typ vun Radio", + "usb_path": "Pad zum USB Apparat" + }, + "description": "Eidel", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/nl.json b/homeassistant/components/zha/.translations/nl.json new file mode 100644 index 00000000000000..e7a3c901c21a4c --- /dev/null +++ b/homeassistant/components/zha/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "user": { + "data": { + "radio_type": "Radio Type", + "usb_path": "USB-apparaatpad" + }, + "description": "Leeg", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json new file mode 100644 index 00000000000000..9db55494ba4ac2 --- /dev/null +++ b/homeassistant/components/zha/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av ZHA er tillatt." + }, + "error": { + "cannot_connect": "Kan ikke koble til ZHA-enhet." + }, + "step": { + "user": { + "data": { + "radio_type": "Radio type", + "usb_path": "USB enhetsbane" + }, + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json new file mode 100644 index 00000000000000..88d4b83ca0dfe8 --- /dev/null +++ b/homeassistant/components/zha/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja ZHA." + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z urz\u0105dzeniem ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Typ radia", + "usb_path": "\u015acie\u017cka urz\u0105dzenia USB" + }, + "description": "Puste", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pt.json b/homeassistant/components/zha/.translations/pt.json new file mode 100644 index 00000000000000..8db9f20dc7bf72 --- /dev/null +++ b/homeassistant/components/zha/.translations/pt.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do ZHA \u00e9 permitida." + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao dispositivo ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Tipo de r\u00e1dio", + "usb_path": "Caminho do Dispositivo USB" + }, + "description": "Vazio" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json new file mode 100644 index 00000000000000..cd618072592422 --- /dev/null +++ b/homeassistant/components/zha/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "step": { + "user": { + "data": { + "radio_type": "\u0422\u0438\u043f \u0420\u0430\u0434\u0438\u043e", + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" + }, + "title": "Zigbee Home Automation (ZHA)" + } + }, + "title": "Zigbee Home Automation" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/sl.json b/homeassistant/components/zha/.translations/sl.json new file mode 100644 index 00000000000000..888b9be2bc7c35 --- /dev/null +++ b/homeassistant/components/zha/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Dovoljena je samo ena konfiguracija ZHA." + }, + "error": { + "cannot_connect": "Ne morem se povezati napravo ZHA." + }, + "step": { + "user": { + "data": { + "radio_type": "Vrsta radia", + "usb_path": "USB Pot" + }, + "description": "Prazno", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/zh-Hans.json b/homeassistant/components/zha/.translations/zh-Hans.json new file mode 100644 index 00000000000000..8befb2ee114d7c --- /dev/null +++ b/homeassistant/components/zha/.translations/zh-Hans.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "usb_path": "USB \u8bbe\u5907\u8def\u5f84" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/zh-Hant.json b/homeassistant/components/zha/.translations/zh-Hant.json new file mode 100644 index 00000000000000..24809a59e0bad9 --- /dev/null +++ b/homeassistant/components/zha/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 ZHA\u3002" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 ZHA \u88dd\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "radio_type": "\u7121\u7dda\u96fb\u985e\u578b", + "usb_path": "USB \u88dd\u7f6e\u8def\u5f91" + }, + "description": "\u7a7a\u767d", + "title": "ZHA" + } + }, + "title": "ZHA" + } +} \ No newline at end of file diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 228e589ab01e8f..fb909b6fedf286 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,51 +5,47 @@ https://home-assistant.io/components/zha/ """ import collections -import enum import logging -import time +import os import voluptuous as vol +from homeassistant import config_entries, const as ha_const +from homeassistant.components.zha.entities import ZhaDeviceEntity import homeassistant.helpers.config_validation as cv -from homeassistant import const as ha_const -from homeassistant.helpers import discovery, entity -from homeassistant.util import slugify +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_component import EntityComponent +# Loading the config flow file will register the flow +from . import config_flow # noqa # pylint: disable=unused-import +from . import const as zha_const +from .const import ( + COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, + CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, + DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, + DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, + DEFAULT_RADIO_TYPE, DOMAIN, ZHA_DISCOVERY_NEW, RadioType) + REQUIREMENTS = [ 'bellows==0.7.0', 'zigpy==0.2.0', 'zigpy-xbee==0.1.1', ] -DOMAIN = 'zha' - - -class RadioType(enum.Enum): - """Possible options for radio type in config.""" - - ezsp = 'ezsp' - xbee = 'xbee' - - -CONF_BAUDRATE = 'baudrate' -CONF_DATABASE = 'database_path' -CONF_DEVICE_CONFIG = 'device_config' -CONF_RADIO_TYPE = 'radio_type' -CONF_USB_PATH = 'usb_path' -DATA_DEVICE_CONFIG = 'zha_device_config' - DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ vol.Optional(ha_const.CONF_TYPE): cv.string, }) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Optional(CONF_RADIO_TYPE, default='ezsp'): cv.enum(RadioType), + vol.Optional( + CONF_RADIO_TYPE, + default=DEFAULT_RADIO_TYPE + ): cv.enum(RadioType), CONF_USB_PATH: cv.string, - vol.Optional(CONF_BAUDRATE, default=57600): cv.positive_int, - CONF_DATABASE: cv.string, + vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int, + vol.Optional(CONF_DATABASE): cv.string, vol.Optional(CONF_DEVICE_CONFIG, default={}): vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}), }) @@ -73,8 +69,6 @@ class RadioType(enum.Enum): # Zigbee definitions CENTICELSIUS = 'C-100' -# Key in hass.data dict containing discovery info -DISCOVERY_KEY = 'zha_discovery_info' # Internal definitions APPLICATION_CONTROLLER = None @@ -82,27 +76,60 @@ class RadioType(enum.Enum): async def async_setup(hass, config): + """Set up ZHA from config.""" + hass.data[DATA_ZHA] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + + 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={ + CONF_USB_PATH: conf[CONF_USB_PATH], + CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value + } + )) + return True + + +async def async_setup_entry(hass, config_entry): """Set up ZHA. Will automatically load components to support devices found on the network. """ global APPLICATION_CONTROLLER - usb_path = config[DOMAIN].get(CONF_USB_PATH) - baudrate = config[DOMAIN].get(CONF_BAUDRATE) - radio_type = config[DOMAIN].get(CONF_RADIO_TYPE) - if radio_type == RadioType.ezsp: + hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] + + config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) + + usb_path = config_entry.data.get(CONF_USB_PATH) + baudrate = config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE) + radio_type = config_entry.data.get(CONF_RADIO_TYPE) + if radio_type == RadioType.ezsp.name: import bellows.ezsp from bellows.zigbee.application import ControllerApplication radio = bellows.ezsp.EZSP() - elif radio_type == RadioType.xbee: + radio_description = "EZSP" + elif radio_type == RadioType.xbee.name: import zigpy_xbee.api from zigpy_xbee.zigbee.application import ControllerApplication radio = zigpy_xbee.api.XBee() + radio_description = "XBee" await radio.connect(usb_path, baudrate) + hass.data[DATA_ZHA][DATA_ZHA_RADIO] = radio - database = config[DOMAIN].get(CONF_DATABASE) + if CONF_DATABASE in config: + database = config[CONF_DATABASE] + else: + database = os.path.join(hass.config.config_dir, DEFAULT_DATABASE_NAME) APPLICATION_CONTROLLER = ControllerApplication(radio, database) listener = ApplicationListener(hass, config) APPLICATION_CONTROLLER.add_listener(listener) @@ -112,6 +139,25 @@ async def async_setup(hass, config): hass.async_create_task( listener.async_device_initialized(device, False)) + device_registry = await \ + hass.helpers.device_registry.async_get_registry() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_ZIGBEE, str(APPLICATION_CONTROLLER.ieee))}, + identifiers={(DOMAIN, str(APPLICATION_CONTROLLER.ieee))}, + name="Zigbee Coordinator", + manufacturer="ZHA", + model=radio_description, + ) + + hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(APPLICATION_CONTROLLER.ieee) + + for component in COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup( + config_entry, component) + ) + async def permit(service): """Allow devices to join this network.""" duration = service.data.get(ATTR_DURATION) @@ -132,6 +178,37 @@ async def remove(service): hass.services.async_register(DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[SERVICE_REMOVE]) + def zha_shutdown(event): + """Close radio.""" + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, zha_shutdown) + return True + + +async def async_unload_entry(hass, config_entry): + """Unload ZHA config entry.""" + hass.services.async_remove(DOMAIN, SERVICE_PERMIT) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE) + + dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, []) + for unsub_dispatcher in dispatchers: + unsub_dispatcher() + + for component in COMPONENTS: + await hass.config_entries.async_forward_entry_unload( + config_entry, component) + + # clean up device entities + component = hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] + entity_ids = [entity.entity_id for entity in component.entities] + for entity_id in entity_ids: + await component.async_remove_entity(entity_id) + + _LOGGER.debug("Closing zha radio") + hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() + + del hass.data[DATA_ZHA] return True @@ -144,7 +221,13 @@ def __init__(self, hass, config): self._config = config self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._device_registry = collections.defaultdict(list) - hass.data[DISCOVERY_KEY] = hass.data.get(DISCOVERY_KEY, {}) + zha_const.populate_data() + + for component in COMPONENTS: + hass.data[DATA_ZHA][component] = ( + hass.data[DATA_ZHA].get(component, {}) + ) + hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component def device_joined(self, device): """Handle device joined. @@ -177,8 +260,6 @@ def device_removed(self, device): async def async_device_initialized(self, device, join): """Handle device joined and basic information discovered (async).""" import zigpy.profiles - import homeassistant.components.zha.const as zha_const - zha_const.populate_data() device_manufacturer = device_model = None @@ -194,8 +275,11 @@ async def async_device_initialized(self, device, join): component = None profile_clusters = ([], []) device_key = "{}-{}".format(device.ieee, endpoint_id) - node_config = self._config[DOMAIN][CONF_DEVICE_CONFIG].get( - device_key, {}) + node_config = {} + if CONF_DEVICE_CONFIG in self._config: + node_config = self._config[CONF_DEVICE_CONFIG].get( + device_key, {} + ) if endpoint.profile_id in zigpy.profiles.PROFILES: profile = zigpy.profiles.PROFILES[endpoint.profile_id] @@ -227,15 +311,17 @@ async def async_device_initialized(self, device, join): 'new_join': join, 'unique_id': device_key, } - self._hass.data[DISCOVERY_KEY][device_key] = discovery_info - - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': device_key}, - self._config, - ) + + if join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info + ) + else: + self._hass.data[DATA_ZHA][component][device_key] = ( + discovery_info + ) for cluster in endpoint.in_clusters.values(): await self._attempt_single_cluster_device( @@ -276,7 +362,6 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, device_classes, discovery_attr, is_new_join): """Try to set up an entity from a "bare" cluster.""" - import homeassistant.components.zha.const as zha_const if cluster.cluster_id in profile_clusters: return @@ -311,235 +396,12 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, discovery_info[discovery_attr] = {cluster.cluster_id: cluster} if sub_component: discovery_info.update({'sub_component': sub_component}) - self._hass.data[DISCOVERY_KEY][cluster_key] = discovery_info - - await discovery.async_load_platform( - self._hass, - component, - DOMAIN, - {'discovery_key': cluster_key}, - self._config, - ) - - -class Entity(entity.Entity): - """A base class for ZHA entities.""" - - _domain = None # Must be overridden by subclasses - - def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, - model, application_listener, unique_id, **kwargs): - """Init ZHA entity.""" - self._device_state_attributes = {} - ieee = endpoint.device.ieee - ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - if manufacturer and model is not None: - self.entity_id = "{}.{}_{}_{}_{}{}".format( - self._domain, - slugify(manufacturer), - slugify(model), - ieeetail, - endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, - ) - else: - self.entity_id = "{}.zha_{}_{}{}".format( - self._domain, - ieeetail, - endpoint.endpoint_id, - kwargs.get('entity_suffix', ''), - ) - - self._endpoint = endpoint - self._in_clusters = in_clusters - self._out_clusters = out_clusters - self._state = None - self._unique_id = unique_id - - # Normally the entity itself is the listener. Sub-classes may set this - # to a dict of cluster ID -> listener to receive messages for specific - # clusters separately - self._in_listeners = {} - self._out_listeners = {} - - self._initialized = False - application_listener.register_entity(ieee, self) - - async def async_added_to_hass(self): - """Handle entity addition to hass. - - It is now safe to update the entity state - """ - for cluster_id, cluster in self._in_clusters.items(): - cluster.add_listener(self._in_listeners.get(cluster_id, self)) - for cluster_id, cluster in self._out_clusters.items(): - cluster.add_listener(self._out_listeners.get(cluster_id, self)) - - self._initialized = True - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - return self._device_state_attributes - - def attribute_updated(self, attribute, value): - """Handle an attribute updated on this cluster.""" - pass - - def zdo_command(self, tsn, command_id, args): - """Handle a ZDO command received on this cluster.""" - pass - - -class ZhaDeviceEntity(entity.Entity): - """A base class for ZHA devices.""" - - def __init__(self, device, manufacturer, model, application_listener, - keepalive_interval=7200, **kwargs): - """Init ZHA endpoint entity.""" - self._device_state_attributes = { - 'nwk': '0x{0:04x}'.format(device.nwk), - 'ieee': str(device.ieee), - 'lqi': device.lqi, - 'rssi': device.rssi, - } - ieee = device.ieee - ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) - if manufacturer is not None and model is not None: - self._unique_id = "{}_{}_{}".format( - slugify(manufacturer), - slugify(model), - ieeetail, + if is_new_join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info ) - self._device_state_attributes['friendly_name'] = "{} {}".format( - manufacturer, - model, - ) - else: - self._unique_id = str(ieeetail) - - self._device = device - self._state = 'offline' - self._keepalive_interval = keepalive_interval - - application_listener.register_entity(ieee, self) - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def state(self) -> str: - """Return the state of the entity.""" - return self._state - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - update_time = None - if self._device.last_seen is not None and self._state == 'offline': - time_struct = time.localtime(self._device.last_seen) - update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) - self._device_state_attributes['last_seen'] = update_time - if ('last_seen' in self._device_state_attributes and - self._state != 'offline'): - del self._device_state_attributes['last_seen'] - self._device_state_attributes['lqi'] = self._device.lqi - self._device_state_attributes['rssi'] = self._device.rssi - return self._device_state_attributes - - async def async_update(self): - """Handle polling.""" - if self._device.last_seen is None: - self._state = 'offline' else: - difference = time.time() - self._device.last_seen - if difference > self._keepalive_interval: - self._state = 'offline' - else: - self._state = 'online' - - -def get_discovery_info(hass, discovery_info): - """Get the full discovery info for a device. - - Some of the info that needs to be passed to platforms is not JSON - serializable, so it cannot be put in the discovery_info dictionary. This - component places that info we need to pass to the platform in hass.data, - and this function is a helper for platforms to retrieve the complete - discovery info. - """ - if discovery_info is None: - return - - discovery_key = discovery_info.get('discovery_key', None) - all_discovery_info = hass.data.get(DISCOVERY_KEY, {}) - return all_discovery_info.get(discovery_key, None) - - -async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): - """Swallow all exceptions from network read. - - If we throw during initialization, setup fails. Rather have an entity that - exists, but is in a maybe wrong state, than no entity. This method should - probably only be used during initialization. - """ - try: - result, _ = await cluster.read_attributes( - attributes, - allow_cache=allow_cache, - only_cache=only_cache - ) - return result - except Exception: # pylint: disable=broad-except - return {} - - -async def configure_reporting(entity_id, cluster, attr, skip_bind=False, - min_report=300, max_report=900, - reportable_change=1): - """Configure attribute reporting for a cluster. - - while swallowing the DeliverError exceptions in case of unreachable - devices. - """ - from zigpy.exceptions import DeliveryError - - attr_name = cluster.attributes.get(attr, [attr])[0] - cluster_name = cluster.ep_attribute - if not skip_bind: - try: - res = await cluster.bind() - _LOGGER.debug( - "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: Failed to bind '%s' cluster: %s", - entity_id, cluster_name, str(ex) - ) - - try: - res = await cluster.configure_reporting(attr, min_report, - max_report, reportable_change) - _LOGGER.debug( - "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - entity_id, attr_name, cluster_name, min_report, max_report, - reportable_change, res - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", - entity_id, attr_name, cluster_name, str(ex) - ) + self._hass.data[DATA_ZHA][component][cluster_key] = discovery_info diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py new file mode 100644 index 00000000000000..1c903ec30566f2 --- /dev/null +++ b/homeassistant/components/zha/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for ZHA.""" +from collections import OrderedDict +import os + +import voluptuous as vol + +from homeassistant import config_entries + +from .const import ( + CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, DOMAIN, RadioType) +from .helpers import check_zigpy_connection + + +@config_entries.HANDLERS.register(DOMAIN) +class ZhaFlowHandler(config_entries.ConfigFlow): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a zha config flow start.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + errors = {} + + fields = OrderedDict() + fields[vol.Required(CONF_USB_PATH)] = str + fields[vol.Optional(CONF_RADIO_TYPE, default='ezsp')] = vol.In( + RadioType.list() + ) + + if user_input is not None: + database = os.path.join(self.hass.config.config_dir, + DEFAULT_DATABASE_NAME) + test = await check_zigpy_connection(user_input[CONF_USB_PATH], + user_input[CONF_RADIO_TYPE], + database) + if test: + return self.async_create_entry( + title=user_input[CONF_USB_PATH], data=user_input) + errors['base'] = 'cannot_connect' + + return self.async_show_form( + step_id='user', data_schema=vol.Schema(fields), errors=errors + ) + + async def async_step_import(self, import_info): + """Handle a zha config import.""" + if self._async_current_entries(): + return self.async_abort(reason='single_instance_allowed') + + return self.async_create_entry( + title=import_info[CONF_USB_PATH], + data=import_info + ) diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 88dee57aa70322..9efa847b50cdea 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -1,5 +1,53 @@ """All constants related to the ZHA component.""" +import enum +DOMAIN = 'zha' + +BAUD_RATES = [ + 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000 +] + +DATA_ZHA = 'zha' +DATA_ZHA_CONFIG = 'config' +DATA_ZHA_BRIDGE_ID = 'zha_bridge_id' +DATA_ZHA_RADIO = 'zha_radio' +DATA_ZHA_DISPATCHERS = 'zha_dispatchers' +DATA_ZHA_CORE_COMPONENT = 'zha_core_component' +ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' + +COMPONENTS = [ + 'binary_sensor', + 'fan', + 'light', + 'sensor', + 'switch', +] + +CONF_BAUDRATE = 'baudrate' +CONF_DATABASE = 'database_path' +CONF_DEVICE_CONFIG = 'device_config' +CONF_RADIO_TYPE = 'radio_type' +CONF_USB_PATH = 'usb_path' +DATA_DEVICE_CONFIG = 'zha_device_config' + +DEFAULT_RADIO_TYPE = 'ezsp' +DEFAULT_BAUDRATE = 57600 +DEFAULT_DATABASE_NAME = 'zigbee.db' + + +class RadioType(enum.Enum): + """Possible options for radio type.""" + + ezsp = 'ezsp' + xbee = 'xbee' + + @classmethod + def list(cls): + """Return list of enum's values.""" + return [e.value for e in RadioType] + + +DISCOVERY_KEY = 'zha_discovery_info' DEVICE_CLASS = {} SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {} SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {} @@ -17,7 +65,12 @@ def populate_data(): from zigpy.profiles import PROFILES, zha, zll from homeassistant.components.sensor import zha as sensor_zha - DEVICE_CLASS[zha.PROFILE_ID] = { + if zha.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zha.PROFILE_ID] = {} + if zll.PROFILE_ID not in DEVICE_CLASS: + DEVICE_CLASS[zll.PROFILE_ID] = {} + + DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', zha.DeviceType.REMOTE_CONTROL: 'binary_sensor', @@ -29,8 +82,8 @@ def populate_data(): zha.DeviceType.ON_OFF_LIGHT_SWITCH: 'binary_sensor', zha.DeviceType.DIMMER_SWITCH: 'binary_sensor', zha.DeviceType.COLOR_DIMMER_SWITCH: 'binary_sensor', - } - DEVICE_CLASS[zll.PROFILE_ID] = { + }) + DEVICE_CLASS[zll.PROFILE_ID].update({ zll.DeviceType.ON_OFF_LIGHT: 'light', zll.DeviceType.ON_OFF_PLUGIN_UNIT: 'switch', zll.DeviceType.DIMMABLE_LIGHT: 'light', @@ -43,7 +96,7 @@ def populate_data(): zll.DeviceType.CONTROLLER: 'binary_sensor', zll.DeviceType.SCENE_CONTROLLER: 'binary_sensor', zll.DeviceType.ON_OFF_SENSOR: 'binary_sensor', - } + }) SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({ zcl.clusters.general.OnOff: 'switch', diff --git a/homeassistant/components/zha/entities/__init__.py b/homeassistant/components/zha/entities/__init__.py new file mode 100644 index 00000000000000..c3c3ea163ed667 --- /dev/null +++ b/homeassistant/components/zha/entities/__init__.py @@ -0,0 +1,10 @@ +""" +Entities for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +# flake8: noqa +from .device_entity import ZhaDeviceEntity +from .entity import ZhaEntity diff --git a/homeassistant/components/zha/entities/device_entity.py b/homeassistant/components/zha/entities/device_entity.py new file mode 100644 index 00000000000000..2d2a5d76b817dc --- /dev/null +++ b/homeassistant/components/zha/entities/device_entity.py @@ -0,0 +1,82 @@ +""" +Device entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" + +import time + +from homeassistant.helpers import entity +from homeassistant.util import slugify + + +class ZhaDeviceEntity(entity.Entity): + """A base class for ZHA devices.""" + + def __init__(self, device, manufacturer, model, application_listener, + keepalive_interval=7200, **kwargs): + """Init ZHA endpoint entity.""" + self._device_state_attributes = { + 'nwk': '0x{0:04x}'.format(device.nwk), + 'ieee': str(device.ieee), + 'lqi': device.lqi, + 'rssi': device.rssi, + } + + ieee = device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) + if manufacturer is not None and model is not None: + self._unique_id = "{}_{}_{}".format( + slugify(manufacturer), + slugify(model), + ieeetail, + ) + self._device_state_attributes['friendly_name'] = "{} {}".format( + manufacturer, + model, + ) + else: + self._unique_id = str(ieeetail) + + self._device = device + self._state = 'offline' + self._keepalive_interval = keepalive_interval + + application_listener.register_entity(ieee, self) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def state(self) -> str: + """Return the state of the entity.""" + return self._state + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + update_time = None + if self._device.last_seen is not None and self._state == 'offline': + time_struct = time.localtime(self._device.last_seen) + update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct) + self._device_state_attributes['last_seen'] = update_time + if ('last_seen' in self._device_state_attributes and + self._state != 'offline'): + del self._device_state_attributes['last_seen'] + self._device_state_attributes['lqi'] = self._device.lqi + self._device_state_attributes['rssi'] = self._device.rssi + return self._device_state_attributes + + async def async_update(self): + """Handle polling.""" + if self._device.last_seen is None: + self._state = 'offline' + else: + difference = time.time() - self._device.last_seen + if difference > self._keepalive_interval: + self._state = 'offline' + else: + self._state = 'online' diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py new file mode 100644 index 00000000000000..da8f615a6650f5 --- /dev/null +++ b/homeassistant/components/zha/entities/entity.py @@ -0,0 +1,105 @@ +""" +Entity for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +from homeassistant.components.zha.const import ( + DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN) +from homeassistant.core import callback +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE +from homeassistant.util import slugify + + +class ZhaEntity(entity.Entity): + """A base class for ZHA entities.""" + + _domain = None # Must be overridden by subclasses + + def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, + model, application_listener, unique_id, **kwargs): + """Init ZHA entity.""" + self._device_state_attributes = {} + ieee = endpoint.device.ieee + ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]]) + if manufacturer and model is not None: + self.entity_id = "{}.{}_{}_{}_{}{}".format( + self._domain, + slugify(manufacturer), + slugify(model), + ieeetail, + endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), + ) + self._device_state_attributes['friendly_name'] = "{} {}".format( + manufacturer, + model, + ) + else: + self.entity_id = "{}.zha_{}_{}{}".format( + self._domain, + ieeetail, + endpoint.endpoint_id, + kwargs.get('entity_suffix', ''), + ) + + self._endpoint = endpoint + self._in_clusters = in_clusters + self._out_clusters = out_clusters + self._state = None + self._unique_id = unique_id + + # Normally the entity itself is the listener. Sub-classes may set this + # to a dict of cluster ID -> listener to receive messages for specific + # clusters separately + self._in_listeners = {} + self._out_listeners = {} + + self._initialized = False + application_listener.register_entity(ieee, self) + + async def async_added_to_hass(self): + """Handle entity addition to hass. + + It is now safe to update the entity state + """ + for cluster_id, cluster in self._in_clusters.items(): + cluster.add_listener(self._in_listeners.get(cluster_id, self)) + for cluster_id, cluster in self._out_clusters.items(): + cluster.add_listener(self._out_listeners.get(cluster_id, self)) + + self._initialized = True + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._unique_id + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return self._device_state_attributes + + @callback + def attribute_updated(self, attribute, value): + """Handle an attribute updated on this cluster.""" + pass + + @callback + def zdo_command(self, tsn, command_id, args): + """Handle a ZDO command received on this cluster.""" + pass + + @property + def device_info(self): + """Return a device description for device registry.""" + ieee = str(self._endpoint.device.ieee) + return { + 'connections': {(CONNECTION_ZIGBEE, ieee)}, + 'identifiers': {(DOMAIN, ieee)}, + 'manufacturer': self._endpoint.manufacturer, + 'model': self._endpoint.model, + 'name': self._device_state_attributes['friendly_name'], + 'via_hub': (DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + } diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py new file mode 100644 index 00000000000000..7ae6fbf2d222f6 --- /dev/null +++ b/homeassistant/components/zha/helpers.py @@ -0,0 +1,89 @@ +""" +Helpers for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import asyncio +import logging + +from .const import DEFAULT_BAUDRATE, RadioType + +_LOGGER = logging.getLogger(__name__) + + +async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): + """Swallow all exceptions from network read. + + If we throw during initialization, setup fails. Rather have an entity that + exists, but is in a maybe wrong state, than no entity. This method should + probably only be used during initialization. + """ + try: + result, _ = await cluster.read_attributes( + attributes, + allow_cache=allow_cache, + only_cache=only_cache + ) + return result + except Exception: # pylint: disable=broad-except + return {} + + +async def configure_reporting(entity_id, cluster, attr, skip_bind=False, + min_report=300, max_report=900, + reportable_change=1): + """Configure attribute reporting for a cluster. + + while swallowing the DeliverError exceptions in case of unreachable + devices. + """ + from zigpy.exceptions import DeliveryError + + attr_name = cluster.attributes.get(attr, [attr])[0] + cluster_name = cluster.ep_attribute + if not skip_bind: + try: + res = await cluster.bind() + _LOGGER.debug( + "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to bind '%s' cluster: %s", + entity_id, cluster_name, str(ex) + ) + + try: + res = await cluster.configure_reporting(attr, min_report, + max_report, reportable_change) + _LOGGER.debug( + "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", + entity_id, attr_name, cluster_name, min_report, max_report, + reportable_change, res + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: failed to set reporting for '%s' attr on '%s' cluster: %s", + entity_id, attr_name, cluster_name, str(ex) + ) + + +async def check_zigpy_connection(usb_path, radio_type, database_path): + """Test zigpy radio connection.""" + if radio_type == RadioType.ezsp.name: + import bellows.ezsp + from bellows.zigbee.application import ControllerApplication + radio = bellows.ezsp.EZSP() + elif radio_type == RadioType.xbee.name: + import zigpy_xbee.api + from zigpy_xbee.zigbee.application import ControllerApplication + radio = zigpy_xbee.api.XBee() + try: + await radio.connect(usb_path, DEFAULT_BAUDRATE) + controller = ControllerApplication(radio, database_path) + await asyncio.wait_for(controller.startup(auto_form=True), timeout=30) + radio.close() + except Exception: # pylint: disable=broad-except + return False + return True diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json new file mode 100644 index 00000000000000..b6d7948c0b3a16 --- /dev/null +++ b/homeassistant/components/zha/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "ZHA", + "step": { + "user": { + "title": "ZHA", + "description": "", + "data": { + "usb_path": "USB Device Path", + "radio_type": "Radio Type" + } + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of ZHA is allowed." + }, + "error": { + "cannot_connect": "Unable to connect to ZHA device." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ru.json b/homeassistant/components/zone/.translations/ru.json index f0619f2163c36f..dc408035d0f657 100644 --- a/homeassistant/components/zone/.translations/ru.json +++ b/homeassistant/components/zone/.translations/ru.json @@ -13,7 +13,7 @@ "passive": "\u041f\u0430\u0441\u0441\u0438\u0432\u043d\u0430\u044f", "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" }, - "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0437\u043e\u043d\u044b" + "title": "\u0417\u043e\u043d\u0430" } }, "title": "\u0417\u043e\u043d\u0430" diff --git a/homeassistant/components/zwave/.translations/ca.json b/homeassistant/components/zwave/.translations/ca.json index b617a902374dcb..7849f34bbf9792 100644 --- a/homeassistant/components/zwave/.translations/ca.json +++ b/homeassistant/components/zwave/.translations/ca.json @@ -5,7 +5,7 @@ "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia de Z-Wave" }, "error": { - "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port on hi ha la mem\u00f2ria USB?" + "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port USB on hi ha la mem\u00f2ria?" }, "step": { "user": { diff --git a/homeassistant/components/zwave/.translations/ru.json b/homeassistant/components/zwave/.translations/ru.json index 457bfd3baa8e1e..b6856e4590ace9 100644 --- a/homeassistant/components/zwave/.translations/ru.json +++ b/homeassistant/components/zwave/.translations/ru.json @@ -2,19 +2,19 @@ "config": { "abort": { "already_configured": "\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.", - "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 Z-Wave" + "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043c\u043e\u0436\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0441 \u043e\u0434\u043d\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c Z-Wave" }, "error": { - "option_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 Z-Wave. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u0442\u044c \u043a USB-\u043d\u0430\u043a\u043e\u043f\u0438\u0442\u0435\u043b\u044e." + "option_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 Z-Wave. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443." }, "step": { "user": { "data": { - "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0439 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)", - "usb_path": "\u041f\u0443\u0442\u044c \u043a USB" + "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)", + "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/docs/z-wave/installation/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u043f\u0435\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0445 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438", - "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 Z-Wave" + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/docs/z-wave/installation/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430", + "title": "Z-Wave" } }, "title": "Z-Wave" diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index dd0b36020a4052..6d96192f075ea0 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -420,7 +420,7 @@ def add_node_secure(service): def remove_node(service): """Switch into exclusion mode.""" - _LOGGER.info("Z-Wwave remove_node have been initialized") + _LOGGER.info("Z-Wave remove_node have been initialized") network.controller.remove_node() def cancel_command(service): diff --git a/homeassistant/config.py b/homeassistant/config.py index 5f7107f95ae625..4fc77bd81cdce5 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -332,7 +332,7 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> Dict: """Load YAML from a Home Assistant configuration file. This function allow a component inside the asyncio loop to reload its - configuration by itself. + configuration by itself. Include package merge. This method is a coroutine. """ @@ -341,7 +341,10 @@ def _load_hass_yaml_config() -> Dict: if path is None: raise HomeAssistantError( "Config file not found in: {}".format(hass.config.config_dir)) - return load_yaml_config_file(path) + config = load_yaml_config_file(path) + core_config = config.get(CONF_CORE, {}) + merge_packages_config(hass, config, core_config.get(CONF_PACKAGES, {})) + return config return await hass.async_add_executor_job(_load_hass_yaml_config) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2325f35822fd5d..5c6ced5756f78a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -159,6 +159,7 @@ async def async_step_discovery(info): 'twilio', 'unifi', 'upnp', + 'zha', 'zone', 'zwave' ] diff --git a/homeassistant/const.py b/homeassistant/const.py index 63d4e9f00f5265..cd71c6d994c512 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 = 83 -PATCH_VERSION = '3' +MINOR_VERSION = 84 +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -163,7 +163,6 @@ EVENT_STATE_CHANGED = 'state_changed' EVENT_TIME_CHANGED = 'time_changed' EVENT_CALL_SERVICE = 'call_service' -EVENT_SERVICE_EXECUTED = 'service_executed' EVENT_PLATFORM_DISCOVERED = 'platform_discovered' EVENT_COMPONENT_LOADED = 'component_loaded' EVENT_SERVICE_REGISTERED = 'service_registered' @@ -171,12 +170,15 @@ EVENT_LOGBOOK_ENTRY = 'logbook_entry' EVENT_THEMES_UPDATED = 'themes_updated' EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync' +EVENT_AUTOMATION_TRIGGERED = 'automation_triggered' +EVENT_SCRIPT_STARTED = 'script_started' # #### DEVICE CLASSES #### DEVICE_CLASS_BATTERY = 'battery' DEVICE_CLASS_HUMIDITY = 'humidity' DEVICE_CLASS_ILLUMINANCE = 'illuminance' DEVICE_CLASS_TEMPERATURE = 'temperature' +DEVICE_CLASS_TIMESTAMP = 'timestamp' DEVICE_CLASS_PRESSURE = 'pressure' # #### STATES #### @@ -232,9 +234,6 @@ # Name ATTR_NAME = 'name' -# Data for a SERVICE_EXECUTED event -ATTR_SERVICE_CALL_ID = 'service_call_id' - # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' diff --git a/homeassistant/core.py b/homeassistant/core.py index 1754a8b50141fa..37d1134ef292ab 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,18 +25,18 @@ from async_timeout import timeout import attr import voluptuous as vol -from voluptuous.humanize import humanize_error from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, + ATTR_SERVICE_DATA, ATTR_SECONDS, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED, - EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, + EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__) from homeassistant import loader from homeassistant.exceptions import ( - HomeAssistantError, InvalidEntityFormatError, InvalidStateError) + HomeAssistantError, InvalidEntityFormatError, InvalidStateError, + Unauthorized, ServiceNotFound) from homeassistant.util.async_ import ( run_coroutine_threadsafe, run_callback_threadsafe, fire_coroutine_threadsafe) @@ -954,7 +954,6 @@ def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" self._services = {} # type: Dict[str, Dict[str, Service]] self._hass = hass - self._async_unsub_call_event = None # type: Optional[CALLBACK_TYPE] @property def services(self) -> Dict[str, Dict[str, Service]]: @@ -1010,10 +1009,6 @@ def async_register(self, domain: str, service: str, service_func: Callable, else: self._services[domain] = {service: service_obj} - if self._async_unsub_call_event is None: - self._async_unsub_call_event = self._hass.bus.async_listen( - EVENT_CALL_SERVICE, self._event_to_service_call) - self._hass.bus.async_fire( EVENT_SERVICE_REGISTERED, {ATTR_DOMAIN: domain, ATTR_SERVICE: service} @@ -1092,100 +1087,63 @@ async def async_call(self, domain: str, service: str, This method is a coroutine. """ + domain = domain.lower() + service = service.lower() context = context or Context() - call_id = uuid.uuid4().hex - event_data = { + service_data = service_data or {} + + try: + handler = self._services[domain][service] + except KeyError: + raise ServiceNotFound(domain, service) from None + + if handler.schema: + processed_data = handler.schema(service_data) + else: + processed_data = service_data + + service_call = ServiceCall(domain, service, processed_data, context) + + self._hass.bus.async_fire(EVENT_CALL_SERVICE, { ATTR_DOMAIN: domain.lower(), ATTR_SERVICE: service.lower(), ATTR_SERVICE_DATA: service_data, - ATTR_SERVICE_CALL_ID: call_id, - } + }) if not blocking: - self._hass.bus.async_fire( - EVENT_CALL_SERVICE, event_data, EventOrigin.local, context) + self._hass.async_create_task( + self._safe_execute(handler, service_call)) return None - fut = asyncio.Future() # type: asyncio.Future - - @callback - def service_executed(event: Event) -> None: - """Handle an executed service.""" - if event.data[ATTR_SERVICE_CALL_ID] == call_id: - fut.set_result(True) - unsub() - - unsub = self._hass.bus.async_listen( - EVENT_SERVICE_EXECUTED, service_executed) - - self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data, - EventOrigin.local, context) - - done, _ = await asyncio.wait([fut], timeout=SERVICE_CALL_LIMIT) - success = bool(done) - if not success: - unsub() - return success - - async def _event_to_service_call(self, event: Event) -> None: - """Handle the SERVICE_CALLED events from the EventBus.""" - service_data = event.data.get(ATTR_SERVICE_DATA) or {} - domain = event.data.get(ATTR_DOMAIN).lower() # type: ignore - service = event.data.get(ATTR_SERVICE).lower() # type: ignore - call_id = event.data.get(ATTR_SERVICE_CALL_ID) - - if not self.has_service(domain, service): - if event.origin == EventOrigin.local: - _LOGGER.warning("Unable to find service %s/%s", - domain, service) - return - - service_handler = self._services[domain][service] - - def fire_service_executed() -> None: - """Fire service executed event.""" - if not call_id: - return - - data = {ATTR_SERVICE_CALL_ID: call_id} - - if (service_handler.is_coroutinefunction or - service_handler.is_callback): - self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, data, - EventOrigin.local, event.context) - else: - self._hass.bus.fire(EVENT_SERVICE_EXECUTED, data, - EventOrigin.local, event.context) - try: - if service_handler.schema: - service_data = service_handler.schema(service_data) - except vol.Invalid as ex: - _LOGGER.error("Invalid service data for %s.%s: %s", - domain, service, humanize_error(service_data, ex)) - fire_service_executed() - return - - service_call = ServiceCall( - domain, service, service_data, event.context) + with timeout(SERVICE_CALL_LIMIT): + await asyncio.shield( + self._execute_service(handler, service_call)) + return True + except asyncio.TimeoutError: + return False + async def _safe_execute(self, handler: Service, + service_call: ServiceCall) -> None: + """Execute a service and catch exceptions.""" try: - if service_handler.is_callback: - service_handler.func(service_call) - fire_service_executed() - elif service_handler.is_coroutinefunction: - await service_handler.func(service_call) - fire_service_executed() - else: - def execute_service() -> None: - """Execute a service and fires a SERVICE_EXECUTED event.""" - service_handler.func(service_call) - fire_service_executed() - - await self._hass.async_add_executor_job(execute_service) + await self._execute_service(handler, service_call) + except Unauthorized: + _LOGGER.warning('Unauthorized service called %s/%s', + service_call.domain, service_call.service) except Exception: # pylint: disable=broad-except _LOGGER.exception('Error executing service %s', service_call) + async def _execute_service(self, handler: Service, + service_call: ServiceCall) -> None: + """Execute a service.""" + if handler.is_callback: + handler.func(service_call) + elif handler.is_coroutinefunction: + await handler.func(service_call) + else: + await self._hass.async_add_executor_job(handler.func, service_call) + class Config: """Configuration settings for Home Assistant.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 0613b7cb10c56b..5e2ab4988b1c7a 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -58,3 +58,14 @@ def __init__(self, context: Optional['Context'] = None, class UnknownUser(Unauthorized): """When call is made with user ID that doesn't exist.""" + + +class ServiceNotFound(HomeAssistantError): + """Raised when a service is not found.""" + + def __init__(self, domain: str, service: str) -> None: + """Initialize error.""" + super().__init__( + self, "Service {}.{} not found".format(domain, service)) + self.domain = domain + self.service = service diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 687ed0b6f8b14b..2d4ad68dbbed9b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -363,10 +363,7 @@ def async_on_remove(self, func): async def async_remove(self): """Remove entity from Home Assistant.""" - will_remove = getattr(self, 'async_will_remove_from_hass', None) - - if will_remove: - await will_remove() # pylint: disable=not-callable + await self.async_will_remove_from_hass() if self._on_remove is not None: while self._on_remove: @@ -390,6 +387,12 @@ async def readd(): self.hass.async_create_task(readd()) + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + def __eq__(self, other): """Return the comparison.""" if not isinstance(other, self.__class__): diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ec7b557934228f..ece0fbd071a369 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -346,8 +346,7 @@ async def _async_add_entity(self, entity, update_before_add, self.entities[entity_id] = entity entity.async_on_remove(lambda: self.entities.pop(entity_id)) - if hasattr(entity, 'async_added_to_hass'): - await entity.async_added_to_hass() + await entity.async_added_to_hass() await entity.async_update_ha_state() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index c40d14652ad26b..57c8bcf0af884c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,6 +10,7 @@ from collections import OrderedDict from itertools import chain import logging +from typing import Optional import weakref import attr @@ -85,6 +86,11 @@ def async_is_registered(self, entity_id): """Check if an entity_id is currently registered.""" return entity_id in self.entities + @callback + def async_get(self, entity_id: str) -> Optional[RegistryEntry]: + """Get EntityEntry for an entity_id.""" + return self.entities.get(entity_id) + @callback def async_get_entity_id(self, domain: str, platform: str, unique_id: str): """Check if an entity_id is currently registered.""" diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index eb88a3db369a93..33b612b555a035 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -1,98 +1,212 @@ """Support for restoring entity states on startup.""" import asyncio import logging -from datetime import timedelta +from datetime import timedelta, datetime +from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import -import async_timeout - -from homeassistant.core import HomeAssistant, CoreState, callback -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.loader import bind_hass -from homeassistant.components.history import get_states, last_recorder_run -from homeassistant.components.recorder import ( - wait_connection_ready, DOMAIN as _RECORDER) +from homeassistant.core import HomeAssistant, callback, State, CoreState +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.storage import Store # noqa pylint_disable=unused-import -RECORDER_TIMEOUT = 10 -DATA_RESTORE_CACHE = 'restore_state_cache' -_LOCK = 'restore_lock' -_LOGGER = logging.getLogger(__name__) - - -def _load_restore_cache(hass: HomeAssistant): - """Load the restore cache to be used by other components.""" - @callback - def remove_cache(event): - """Remove the states cache.""" - hass.data.pop(DATA_RESTORE_CACHE, None) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, remove_cache) - - last_run = last_recorder_run(hass) - - if last_run is None or last_run.end is None: - _LOGGER.debug('Not creating cache - no suitable last run found: %s', - last_run) - hass.data[DATA_RESTORE_CACHE] = {} - return - - last_end_time = last_run.end - timedelta(seconds=1) - # Unfortunately the recorder_run model do not return offset-aware time - last_end_time = last_end_time.replace(tzinfo=dt_util.UTC) - _LOGGER.debug("Last run: %s - %s", last_run.start, last_end_time) - - states = get_states(hass, last_end_time, run=last_run) - - # Cache the states - hass.data[DATA_RESTORE_CACHE] = { - state.entity_id: state for state in states} - _LOGGER.debug('Created cache with %s', list(hass.data[DATA_RESTORE_CACHE])) +DATA_RESTORE_STATE_TASK = 'restore_state_task' +_LOGGER = logging.getLogger(__name__) -@bind_hass -async def async_get_last_state(hass, entity_id: str): - """Restore state.""" - if DATA_RESTORE_CACHE in hass.data: - return hass.data[DATA_RESTORE_CACHE].get(entity_id) - - if _RECORDER not in hass.config.components: - return None - - if hass.state not in (CoreState.starting, CoreState.not_running): - _LOGGER.debug("Cache for %s can only be loaded during startup, not %s", - entity_id, hass.state) - return None - - try: - with async_timeout.timeout(RECORDER_TIMEOUT, loop=hass.loop): - connected = await wait_connection_ready(hass) - except asyncio.TimeoutError: - return None - - if not connected: - return None - - if _LOCK not in hass.data: - hass.data[_LOCK] = asyncio.Lock(loop=hass.loop) - - async with hass.data[_LOCK]: - if DATA_RESTORE_CACHE not in hass.data: - await hass.async_add_job( - _load_restore_cache, hass) +STORAGE_KEY = 'core.restore_state' +STORAGE_VERSION = 1 + +# How long between periodically saving the current states to disk +STATE_DUMP_INTERVAL = timedelta(minutes=15) + +# How long should a saved state be preserved if the entity no longer exists +STATE_EXPIRATION = timedelta(days=7) - return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) +class StoredState: + """Object to represent a stored state.""" + + def __init__(self, state: State, last_seen: datetime) -> None: + """Initialize a new stored state.""" + self.state = state + self.last_seen = last_seen -async def async_restore_state(entity, extract_info): - """Call entity.async_restore_state with cached info.""" - if entity.hass.state not in (CoreState.starting, CoreState.not_running): - _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", - entity.entity_id, entity.hass.state) - return + def as_dict(self) -> Dict: + """Return a dict representation of the stored state.""" + return { + 'state': self.state.as_dict(), + 'last_seen': self.last_seen, + } + + @classmethod + def from_dict(cls, json_dict: Dict) -> 'StoredState': + """Initialize a stored state from a dict.""" + last_seen = json_dict['last_seen'] + + if isinstance(last_seen, str): + last_seen = dt_util.parse_datetime(last_seen) + + return cls(State.from_dict(json_dict['state']), last_seen) + + +class RestoreStateData(): + """Helper class for managing the helper saved data.""" + + @classmethod + async def async_get_instance( + cls, hass: HomeAssistant) -> 'RestoreStateData': + """Get the singleton instance of this data helper.""" + task = hass.data.get(DATA_RESTORE_STATE_TASK) + + if task is None: + async def load_instance(hass: HomeAssistant) -> 'RestoreStateData': + """Set up the restore state helper.""" + data = cls(hass) + + try: + stored_states = await data.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + stored_states = None + + if stored_states is None: + _LOGGER.debug('Not creating cache - no saved states found') + data.last_states = {} + else: + data.last_states = { + item['state']['entity_id']: StoredState.from_dict(item) + for item in stored_states} + _LOGGER.debug( + 'Created cache with %s', list(data.last_states)) + + if hass.state == CoreState.running: + data.async_setup_dump() + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, data.async_setup_dump) + + return data + + task = hass.data[DATA_RESTORE_STATE_TASK] = hass.async_create_task( + load_instance(hass)) + + return await task + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the restore state data class.""" + self.hass = hass # type: HomeAssistant + self.store = Store( + hass, STORAGE_VERSION, STORAGE_KEY, + encoder=JSONEncoder) # type: Store + self.last_states = {} # type: Dict[str, StoredState] + self.entity_ids = set() # type: Set[str] + + def async_get_stored_states(self) -> List[StoredState]: + """Get the set of states which should be stored. + + This includes the states of all registered entities, as well as the + stored states from the previous run, which have not been created as + entities on this run, and have not expired. + """ + now = dt_util.utcnow() + all_states = self.hass.states.async_all() + current_entity_ids = set(state.entity_id for state in all_states) + + # Start with the currently registered states + stored_states = [StoredState(state, now) for state in all_states + if state.entity_id in self.entity_ids] + + expiration_time = now - STATE_EXPIRATION + + for entity_id, stored_state in self.last_states.items(): + # Don't save old states that have entities in the current run + if entity_id in current_entity_ids: + continue + + # Don't save old states that have expired + if stored_state.last_seen < expiration_time: + continue + + stored_states.append(stored_state) + + return stored_states + + async def async_dump_states(self) -> None: + """Save the current state machine to storage.""" + _LOGGER.debug("Dumping states") + try: + await self.store.async_save([ + stored_state.as_dict() + for stored_state in self.async_get_stored_states()]) + except HomeAssistantError as exc: + _LOGGER.error("Error saving current states", exc_info=exc) - state = await async_get_last_state(entity.hass, entity.entity_id) + @callback + def async_setup_dump(self, *args: Any) -> None: + """Set up the restore state listeners.""" + # Dump the initial states now. This helps minimize the risk of having + # old states loaded by overwritting the last states once home assistant + # has started and the old states have been read. + self.hass.async_create_task(self.async_dump_states()) + + # Dump states periodically + async_track_time_interval( + self.hass, lambda *_: self.hass.async_create_task( + self.async_dump_states()), STATE_DUMP_INTERVAL) + + # Dump states when stopping hass + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, lambda *_: self.hass.async_create_task( + self.async_dump_states())) - if not state: - return + @callback + def async_restore_entity_added(self, entity_id: str) -> None: + """Store this entity's state when hass is shutdown.""" + self.entity_ids.add(entity_id) - await entity.async_restore_state(**extract_info(state)) + @callback + def async_restore_entity_removed(self, entity_id: str) -> None: + """Unregister this entity from saving state.""" + # When an entity is being removed from hass, store its last state. This + # allows us to support state restoration if the entity is removed, then + # re-added while hass is still running. + self.last_states[entity_id] = StoredState( + self.hass.states.get(entity_id), dt_util.utcnow()) + + self.entity_ids.remove(entity_id) + + +class RestoreEntity(Entity): + """Mixin class for restoring previous entity state.""" + + async def async_added_to_hass(self) -> None: + """Register this entity as a restorable entity.""" + _, data = await asyncio.gather( + super().async_added_to_hass(), + RestoreStateData.async_get_instance(self.hass), + ) + data.async_restore_entity_added(self.entity_id) + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + _, data = await asyncio.gather( + super().async_will_remove_from_hass(), + RestoreStateData.async_get_instance(self.hass), + ) + data.async_restore_entity_removed(self.entity_id) + + async def async_get_last_state(self) -> Optional[State]: + """Get the entity state from the previous run.""" + if self.hass is None or self.entity_id is None: + # Return None if this entity isn't added to hass yet + _LOGGER.warning("Cannot get last state. Entity not added to hass") + return None + data = await RestoreStateData.async_get_instance(self.hass) + if self.entity_id not in data.last_states: + return None + return data.last_states[self.entity_id].state diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 80d66f4fac8817..088882df608f75 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant, Context, callback from homeassistant.const import CONF_CONDITION, CONF_TIMEOUT -from homeassistant.exceptions import TemplateError +from homeassistant import exceptions from homeassistant.helpers import ( service, condition, template as template, config_validation as cv) @@ -34,6 +34,30 @@ CONF_CONTINUE = 'continue_on_timeout' +ACTION_DELAY = 'delay' +ACTION_WAIT_TEMPLATE = 'wait_template' +ACTION_CHECK_CONDITION = 'condition' +ACTION_FIRE_EVENT = 'event' +ACTION_CALL_SERVICE = 'call_service' + + +def _determine_action(action): + """Determine action type.""" + if CONF_DELAY in action: + return ACTION_DELAY + + if CONF_WAIT_TEMPLATE in action: + return ACTION_WAIT_TEMPLATE + + if CONF_CONDITION in action: + return ACTION_CHECK_CONDITION + + if CONF_EVENT in action: + return ACTION_FIRE_EVENT + + return ACTION_CALL_SERVICE + + def call_from_config(hass: HomeAssistant, config: ConfigType, variables: Optional[Sequence] = None, context: Optional[Context] = None) -> None: @@ -41,6 +65,14 @@ def call_from_config(hass: HomeAssistant, config: ConfigType, Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context) +class _StopScript(Exception): + """Throw if script needs to stop.""" + + +class _SuspendScript(Exception): + """Throw if script needs to suspend.""" + + class Script(): """Representation of a script.""" @@ -60,6 +92,13 @@ def __init__(self, hass: HomeAssistant, sequence, name: str = None, self._async_listener = [] self._template_cache = {} self._config_cache = {} + self._actions = { + ACTION_DELAY: self._async_delay, + ACTION_WAIT_TEMPLATE: self._async_wait_template, + ACTION_CHECK_CONDITION: self._async_check_condition, + ACTION_FIRE_EVENT: self._async_fire_event, + ACTION_CALL_SERVICE: self._async_call_service, + } @property def is_running(self) -> bool: @@ -87,98 +126,27 @@ async def async_run(self, variables: Optional[Sequence] = None, self._async_remove_listener() for cur, action in islice(enumerate(self.sequence), self._cur, None): - - if CONF_DELAY in action: - # Call ourselves in the future to continue work - unsub = None - - @callback - def async_script_delay(now): - """Handle delay.""" - # pylint: disable=cell-var-from-loop - with suppress(ValueError): - self._async_listener.remove(unsub) - - self.hass.async_create_task( - self.async_run(variables, context)) - - delay = action[CONF_DELAY] - - try: - if isinstance(delay, template.Template): - delay = vol.All( - cv.time_period, - cv.positive_timedelta)( - delay.async_render(variables)) - elif isinstance(delay, dict): - delay_data = {} - delay_data.update( - template.render_complex(delay, variables)) - delay = cv.time_period(delay_data) - except (TemplateError, vol.Invalid) as ex: - _LOGGER.error("Error rendering '%s' delay template: %s", - 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 - ) - self._async_listener.append(unsub) - - self._cur = cur + 1 - if self._change_listener: - self.hass.async_add_job(self._change_listener) - return - - if CONF_WAIT_TEMPLATE in action: - # Call ourselves in the future to continue work - 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): - continue - - @callback - def async_script_wait(entity_id, from_s, to_s): - """Handle script after template condition is true.""" - self._async_remove_listener() - self.hass.async_create_task( - self.async_run(variables, context)) - - self._async_listener.append(async_track_template( - self.hass, wait_template, async_script_wait, variables)) - + try: + await self._handle_action(action, variables, context) + except _SuspendScript: + # Store next step to take and notify change listeners self._cur = cur + 1 if self._change_listener: self.hass.async_add_job(self._change_listener) - - if CONF_TIMEOUT in action: - self._async_set_timeout( - action, variables, context, - action.get(CONF_CONTINUE, True)) - return - - if CONF_CONDITION in action: - if not self._async_check_condition(action, variables): - break - - elif CONF_EVENT in action: - self._async_fire_event(action, variables, context) - - else: - await self._async_call_service(action, variables, context) - + except _StopScript: + break + except Exception as err: + # Store the step that had an exception + # pylint: disable=protected-access + err._script_step = cur + # Set script to not running + self._cur = -1 + self.last_action = None + # Pass exception on. + raise + + # Set script to not-running. self._cur = -1 self.last_action = None if self._change_listener: @@ -198,6 +166,86 @@ def async_stop(self) -> None: if self._change_listener: self.hass.async_add_job(self._change_listener) + async def _handle_action(self, action, variables, context): + """Handle an action.""" + await self._actions[_determine_action(action)]( + action, variables, context) + + async def _async_delay(self, action, variables, context): + """Handle delay.""" + # Call ourselves in the future to continue work + unsub = None + + @callback + def async_script_delay(now): + """Handle delay.""" + # pylint: disable=cell-var-from-loop + with suppress(ValueError): + self._async_listener.remove(unsub) + + self.hass.async_create_task( + self.async_run(variables, context)) + + delay = action[CONF_DELAY] + + try: + if isinstance(delay, template.Template): + delay = vol.All( + cv.time_period, + cv.positive_timedelta)( + delay.async_render(variables)) + elif isinstance(delay, dict): + delay_data = {} + delay_data.update( + template.render_complex(delay, variables)) + delay = cv.time_period(delay_data) + except (exceptions.TemplateError, vol.Invalid) as ex: + _LOGGER.error("Error rendering '%s' delay template: %s", + self.name, ex) + raise _StopScript + + 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 + ) + self._async_listener.append(unsub) + raise _SuspendScript + + async def _async_wait_template(self, action, variables, context): + """Handle a wait template.""" + # Call ourselves in the future to continue work + 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): + return + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + self._async_remove_listener() + self.hass.async_create_task( + self.async_run(variables, context)) + + self._async_listener.append(async_track_template( + self.hass, wait_template, async_script_wait, variables)) + + if CONF_TIMEOUT in action: + self._async_set_timeout( + action, variables, context, + action.get(CONF_CONTINUE, True)) + + raise _SuspendScript + async def _async_call_service(self, action, variables, context): """Call the service specified in the action. @@ -213,7 +261,7 @@ async def _async_call_service(self, action, variables, context): context=context ) - def _async_fire_event(self, action, variables, context): + async def _async_fire_event(self, action, variables, context): """Fire an event.""" self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) self._log("Executing step %s" % self.last_action) @@ -222,13 +270,13 @@ def _async_fire_event(self, action, variables, context): try: event_data.update(template.render_complex( action[CONF_EVENT_DATA_TEMPLATE], variables)) - except TemplateError as ex: + except exceptions.TemplateError as ex: _LOGGER.error('Error rendering event data template: %s', ex) self.hass.bus.async_fire(action[CONF_EVENT], event_data, context=context) - def _async_check_condition(self, action, variables): + async def _async_check_condition(self, action, variables, context): """Test if condition is matching.""" config_cache_key = frozenset((k, str(v)) for k, v in action.items()) config = self._config_cache.get(config_cache_key) @@ -239,7 +287,9 @@ def _async_check_condition(self, action, variables): self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) check = config(self.hass, variables) self._log("Test condition {}: {}".format(self.last_action, check)) - return check + + if not check: + raise _StopScript def _async_set_timeout(self, action, variables, context, continue_on_timeout): diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index cfe73d6d1476a4..5fbb7700458675 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -1,13 +1,14 @@ """Helper to help store data.""" import asyncio +from json import JSONEncoder import logging import os -from typing import Dict, Optional, Callable, Any +from typing import Dict, List, Optional, Callable, Union from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback from homeassistant.loader import bind_hass -from homeassistant.util import json +from homeassistant.util import json as json_util from homeassistant.helpers.event import async_call_later STORAGE_DIR = '.storage' @@ -16,7 +17,7 @@ @bind_hass async def async_migrator(hass, old_path, store, *, - old_conf_load_func=json.load_json, + old_conf_load_func=json_util.load_json, old_conf_migrate_func=None): """Migrate old data to a store and then load data. @@ -46,7 +47,8 @@ def load_old_config(): class Store: """Class to help storing data.""" - def __init__(self, hass, version: int, key: str, private: bool = False): + def __init__(self, hass, version: int, key: str, private: bool = False, *, + encoder: JSONEncoder = None): """Initialize storage class.""" self.version = version self.key = key @@ -57,13 +59,14 @@ def __init__(self, hass, version: int, key: str, private: bool = False): self._unsub_stop_listener = None self._write_lock = asyncio.Lock(loop=hass.loop) self._load_task = None + self._encoder = encoder @property def path(self): """Return the config path.""" return self.hass.config.path(STORAGE_DIR, self.key) - async def async_load(self) -> Optional[Dict[str, Any]]: + async def async_load(self) -> Optional[Union[Dict, List]]: """Load data. If the expected version does not match the given version, the migrate @@ -88,7 +91,7 @@ async def _async_load(self): data['data'] = data.pop('data_func')() else: data = await self.hass.async_add_executor_job( - json.load_json, self.path) + json_util.load_json, self.path) if data == {}: return None @@ -103,7 +106,7 @@ async def _async_load(self): self._load_task = None return stored - async def async_save(self, data): + async def async_save(self, data: Union[Dict, List]) -> None: """Save data.""" self._data = { 'version': self.version, @@ -178,7 +181,7 @@ async def _async_handle_write_data(self, *_args): try: await self.hass.async_add_executor_job( self._write_data, self.path, data) - except (json.SerializationError, json.WriteError) as err: + except (json_util.SerializationError, json_util.WriteError) as err: _LOGGER.error('Error writing config for %s: %s', self.key, err) def _write_data(self, path: str, data: Dict): @@ -187,7 +190,7 @@ def _write_data(self, path: str, data: Dict): os.makedirs(os.path.dirname(path)) _LOGGER.debug('Writing data for %s', self.key) - json.save_json(path, data, self._private) + json_util.save_json(path, data, self._private, encoder=self._encoder) async def _async_migrate_func(self, old_version, old_data): """Migrate to the new version.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 66f289724befe6..2173f972cba819 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -4,6 +4,7 @@ import logging import math import random +import base64 import re import jinja2 @@ -169,8 +170,10 @@ def async_render_with_possible_json_value(self, value, try: return self._compiled.render(variables).strip() except jinja2.TemplateError as ex: - _LOGGER.error("Error parsing value: %s (value: %s, template: %s)", - ex, value, self.template) + if error_value is _SENTINEL: + _LOGGER.error( + "Error parsing value: %s (value: %s, template: %s)", + ex, value, self.template) return value if error_value is _SENTINEL else error_value def _ensure_compiled(self): @@ -602,6 +605,24 @@ def bitwise_or(first_value, second_value): return first_value | second_value +def base64_encode(value): + """Perform base64 encode.""" + return base64.b64encode(value.encode('utf-8')).decode('utf-8') + + +def base64_decode(value): + """Perform base64 denode.""" + return base64.b64decode(value).decode('utf-8') + + +def ordinal(value): + """Perform ordinal conversion.""" + return str(value) + (list(['th', 'st', 'nd', 'rd'] + ['th'] * 6) + [(int(str(value)[-1])) % 10] if + int(str(value)[-2:]) % 100 not in range(11, 14) + else 'th') + + @contextfilter def random_every_time(context, values): """Choose a random value. @@ -640,6 +661,9 @@ def is_safe_attribute(self, obj, attr, value): ENV.filters['max'] = max ENV.filters['min'] = min ENV.filters['random'] = random_every_time +ENV.filters['base64_encode'] = base64_encode +ENV.filters['base64_decode'] = base64_decode +ENV.filters['ordinal'] = ordinal ENV.filters['regex_match'] = regex_match ENV.filters['regex_replace'] = regex_replace ENV.filters['regex_search'] = regex_search diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 11f9659170549a..7236380d42a7ac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,6 +4,7 @@ async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 +idna==2.7 jinja2>=2.10 PyJWT==1.6.4 cryptography==2.3.1 @@ -11,7 +12,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.1 -ruamel.yaml==0.15.78 +ruamel.yaml==0.15.80 voluptuous==0.11.5 voluptuous-serialize==2.0.0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 1e77454a8d57aa..ac341e8f58a3d8 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -327,11 +327,6 @@ def _comp_error(ex, domain, config): hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error) core_config.pop(CONF_PACKAGES, None) - # Ensure we have no None values after merge - for key, value in config.items(): - if not value: - config[key] = {} - # Filter out repeating config sections components = set(key.split(' ')[0] for key in config.keys()) diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 76a9d9318f206e..16c2638f26e11e 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==15.1.0', 'keyrings.alt==3.1'] +REQUIREMENTS = ['keyring==17.0.0', 'keyrings.alt==3.1'] def run(args): diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist index 920f45a0c0e9e0..19b182a4cd56b8 100644 --- a/homeassistant/scripts/macos/launchd.plist +++ b/homeassistant/scripts/macos/launchd.plist @@ -8,7 +8,7 @@ EnvironmentVariables PATH - /usr/local/bin/:/usr/bin:/usr/sbin:$PATH + /usr/local/bin/:/usr/bin:/usr/sbin:/sbin:$PATH LC_CTYPE UTF-8 diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py new file mode 100644 index 00000000000000..d648ed43110f2e --- /dev/null +++ b/homeassistant/util/aiohttp.py @@ -0,0 +1,53 @@ +"""Utilities to help with aiohttp.""" +import json +from urllib.parse import parse_qsl +from typing import Any, Dict, Optional + +from aiohttp import web +from multidict import CIMultiDict, MultiDict + + +class MockRequest: + """Mock an aiohttp request.""" + + def __init__(self, content: bytes, method: str = 'GET', + status: int = 200, headers: Optional[Dict[str, str]] = None, + query_string: Optional[str] = None, url: str = '') -> None: + """Initialize a request.""" + self.method = method + self.url = url + self.status = status + self.headers = CIMultiDict(headers or {}) # type: CIMultiDict[str] + self.query_string = query_string or '' + self._content = content + + @property + def query(self) -> 'MultiDict[str]': + """Return a dictionary with the query variables.""" + return MultiDict(parse_qsl(self.query_string, keep_blank_values=True)) + + @property + def _text(self) -> str: + """Return the body as text.""" + return self._content.decode('utf-8') + + async def json(self) -> Any: + """Return the body as JSON.""" + return json.loads(self._text) + + async def post(self) -> 'MultiDict[str]': + """Return POST parameters.""" + return MultiDict(parse_qsl(self._text, keep_blank_values=True)) + + async def text(self) -> str: + """Return the body as text.""" + return self._text + + +def serialize_response(response: web.Response) -> Dict[str, Any]: + """Serialize an aiohttp response to a dictionary.""" + return { + 'status': response.status, + 'body': response.body, + 'headers': dict(response.headers), + } diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index b002c8e3147229..8ca1c702b6c68d 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -1,6 +1,6 @@ """JSON utility functions.""" import logging -from typing import Union, List, Dict +from typing import Union, List, Dict, Optional import json import os @@ -41,7 +41,8 @@ def load_json(filename: str, default: Union[List, Dict, None] = None) \ def save_json(filename: str, data: Union[List, Dict], - private: bool = False) -> None: + private: bool = False, *, + encoder: Optional[json.JSONEncoder] = None) -> None: """Save JSON data to a file. Returns True on success. @@ -49,7 +50,7 @@ def save_json(filename: str, data: Union[List, Dict], tmp_filename = "" tmp_path = os.path.split(filename)[0] try: - json_data = json.dumps(data, sort_keys=True, indent=4) + json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder) # Modern versions of Python tempfile create this file with mode 0o600 with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8', dir=tmp_path, delete=False) as fdesc: diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py index 8211252a516d21..0659e3d80544d4 100644 --- a/homeassistant/util/ruamel_yaml.py +++ b/homeassistant/util/ruamel_yaml.py @@ -1,7 +1,7 @@ """ruamel.yaml utility functions.""" import logging import os -from os import O_CREAT, O_TRUNC, O_WRONLY +from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result from collections import OrderedDict from typing import Union, List, Dict @@ -104,13 +104,17 @@ def save_yaml(fname: str, data: JSON_TYPE) -> None: yaml.indent(sequence=4, offset=2) tmp_fname = fname + "__TEMP__" try: - file_stat = os.stat(fname) + try: + file_stat = os.stat(fname) + except OSError: + file_stat = stat_result( + (0o644, -1, -1, -1, -1, -1, -1, -1, -1, -1)) with open(os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC, file_stat.st_mode), 'w', encoding='utf-8') \ as temp_file: yaml.dump(data, temp_file) os.replace(tmp_fname, fname) - if hasattr(os, 'chown'): + if hasattr(os, 'chown') and file_stat.st_ctime > -1: try: os.chown(fname, file_stat.st_uid, file_stat.st_gid) except OSError: diff --git a/pylintrc b/pylintrc index be06f83e6f256b..a88aabe1936f1a 100644 --- a/pylintrc +++ b/pylintrc @@ -12,6 +12,7 @@ # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise # not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 +# unnecessary-pass - readability for functions which only contain pass disable= abstract-class-little-used, abstract-method, @@ -32,6 +33,7 @@ disable= too-many-public-methods, too-many-return-statements, too-many-statements, + unnecessary-pass, unused-argument [REPORTS] diff --git a/requirements_all.txt b/requirements_all.txt index 169c54aa713178..ee749157a7a922 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ async_timeout==3.0.1 attrs==18.2.0 bcrypt==3.1.4 certifi>=2018.04.16 +idna==2.7 jinja2>=2.10 PyJWT==1.6.4 cryptography==2.3.1 @@ -12,7 +13,7 @@ pip>=8.0.3 pytz>=2018.04 pyyaml>=3.13,<4 requests==2.20.1 -ruamel.yaml==0.15.78 +ruamel.yaml==0.15.80 voluptuous==0.11.5 voluptuous-serialize==2.0.0 @@ -20,7 +21,7 @@ voluptuous-serialize==2.0.0 --only-binary=all nuimo==0.1.0 # homeassistant.components.sensor.dht -# Adafruit-DHT==1.3.4 +# Adafruit-DHT==1.4.0 # homeassistant.components.sensor.sht31 Adafruit-GPIO==1.0.3 @@ -53,7 +54,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.1.3 # homeassistant.components.switch.switchbot -PySwitchbot==0.3 +PySwitchbot==0.4 # homeassistant.components.sensor.transport_nsw PyTransportNSW==0.1.1 @@ -111,7 +112,7 @@ aiohue==1.5.0 aioimaplib==0.7.13 # homeassistant.components.lifx -aiolifx==0.6.6 +aiolifx==0.6.7 # homeassistant.components.light.lifx aiolifx_effects==0.2.1 @@ -186,7 +187,7 @@ bellows==0.7.0 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.10.3 +blinkpy==0.11.0 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 @@ -230,7 +231,7 @@ bt_proximity==0.1.2 bthomehub5-devicelist==0.1.1 # homeassistant.components.device_tracker.bt_smarthub -btsmarthub_devicelist==0.1.1 +btsmarthub_devicelist==0.1.3 # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar @@ -293,7 +294,7 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.7.6 +denonavr==0.7.7 # homeassistant.components.media_player.directv directpy==0.5 @@ -333,11 +334,14 @@ einder==0.3.1 eliqonline==1.0.14 # homeassistant.components.elkm1 -elkm1-lib==0.7.12 +elkm1-lib==0.7.13 # homeassistant.components.enocean enocean==0.40 +# homeassistant.components.sensor.entur_public_transport +enturclient==0.1.0 + # homeassistant.components.sensor.envirophat # envirophat==0.0.6 @@ -358,7 +362,7 @@ eternalegypt==0.0.5 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # homeassistant.components.image_processing.dlib_face_detect # homeassistant.components.image_processing.dlib_face_identify @@ -416,6 +420,7 @@ geizhals==0.0.7 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed +# homeassistant.components.geo_location.usgs_earthquakes_feed geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events @@ -478,6 +483,9 @@ hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 +# homeassistant.components.hlk_sw16 +hlk-sw16==0.0.6 + # homeassistant.components.sensor.pi_hole hole==0.3.0 @@ -485,7 +493,7 @@ hole==0.3.0 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.1 +home-assistant-frontend==20181211.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.1 @@ -522,7 +530,7 @@ ihcsdk==2.2.0 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==5.0.0 +influxdb==5.2.0 # homeassistant.components.insteon insteonplm==0.15.1 @@ -544,7 +552,7 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==15.1.0 +keyring==17.0.0 # homeassistant.scripts.keyring keyrings.alt==3.1 @@ -568,7 +576,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==2.1.1 +librouteros==2.2.0 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 @@ -579,6 +587,9 @@ liffylights==0.9.4 # homeassistant.components.light.osramlightify lightify==1.0.6.1 +# homeassistant.components.lightwave +lightwave==0.15 + # homeassistant.components.light.limitlessled limitlessled==1.1.3 @@ -593,7 +604,7 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==3.0.8 +locationsharinglib==3.0.9 # homeassistant.components.logi_circle logi_circle==0.1.7 @@ -602,7 +613,7 @@ logi_circle==0.1.7 luftdaten==0.3.4 # homeassistant.components.lupusec -lupupy==0.0.10 +lupupy==0.0.17 # homeassistant.components.light.lw12wifi lw12==0.9.2 @@ -633,7 +644,7 @@ mficlient==0.3.0 miflora==0.4.0 # homeassistant.components.climate.mill -millheater==0.2.8 +millheater==0.2.9 # homeassistant.components.sensor.mitemp_bt mitemp_bt==0.0.1 @@ -645,7 +656,7 @@ motorparts==1.0.2 mutagen==1.41.1 # homeassistant.components.mychevy -mychevy==0.4.0 +mychevy==1.0.1 # homeassistant.components.mycroft mycroftapi==2.0 @@ -744,7 +755,7 @@ pilight==0.1.1 # homeassistant.components.camera.proxy # homeassistant.components.image_processing.tensorflow -pillow==5.2.0 +pillow==5.3.0 # homeassistant.components.dominos pizzapi==0.0.3 @@ -804,7 +815,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.1.0 +py17track==2.1.1 # homeassistant.components.hdmi_cec pyCEC==0.4.13 @@ -820,10 +831,10 @@ pyMetno==0.3.0 pyRFXtrx==0.23 # homeassistant.components.switch.switchmate -pySwitchmate==0.4.3 +pySwitchmate==0.4.4 # homeassistant.components.tibber -pyTibber==0.8.2 +pyTibber==0.8.6 # homeassistant.components.switch.dlink pyW215==0.6.0 @@ -847,10 +858,10 @@ pyalarmdotcom==0.3.2 pyarlo==0.2.2 # homeassistant.components.netatmo -pyatmo==1.2 +pyatmo==1.4 # homeassistant.components.apple_tv -pyatv==0.3.10 +pyatv==0.3.12 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -967,7 +978,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.52 +pyhomematic==0.1.53 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -979,7 +990,7 @@ pyialarm==0.3 pyicloud==0.9.1 # homeassistant.components.weather.ipma -pyipma==1.1.4 +pyipma==1.1.6 # homeassistant.components.sensor.irish_rail_transport pyirishrail==0.0.2 @@ -1131,7 +1142,7 @@ pyserial==3.1.1 pysesame==0.1.0 # homeassistant.components.goalfeed -pysher==1.0.4 +pysher==1.0.1 # homeassistant.components.sensor.sma pysma==0.2.2 @@ -1139,7 +1150,7 @@ pysma==0.2.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.5 +pysnmp==4.4.6 # homeassistant.components.sonos pysonos==0.0.5 @@ -1214,7 +1225,7 @@ python-juicenet==0.0.5 # homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.4.3 +python-miio==0.4.4 # homeassistant.components.media_player.mpd python-mpd2==1.0.0 @@ -1232,6 +1243,9 @@ python-nmap==0.6.1 # homeassistant.components.notify.pushover python-pushover==0.3 +# homeassistant.components.sensor.qbittorrent +python-qbittorrent==0.3.1 + # homeassistant.components.sensor.ripple python-ripple-api==0.0.3 @@ -1265,6 +1279,9 @@ python-vlc==1.1.2 # homeassistant.components.wink python-wink==1.10.1 +# homeassistant.components.sensor.awair +python_awair==0.0.3 + # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.4 @@ -1308,7 +1325,7 @@ pyvera==0.2.45 pyvesync==0.1.1 # homeassistant.components.media_player.vizio -pyvizio==0.0.3 +pyvizio==0.0.4 # homeassistant.components.velux pyvlx==0.1.3 @@ -1317,7 +1334,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.29 +pywemo==0.4.33 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 @@ -1347,7 +1364,7 @@ raincloudy==0.0.5 regenmaschine==1.0.7 # homeassistant.components.python_script -restrictedpython==4.0b6 +restrictedpython==4.0b7 # homeassistant.components.rflink rflink==0.0.37 @@ -1408,16 +1425,16 @@ shodan==1.10.4 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==3.1.13 +simplisafe-python==3.1.14 # homeassistant.components.sisyphus sisyphus-control==2.1 # homeassistant.components.skybell -skybellpy==0.1.2 +skybellpy==0.2.0 # homeassistant.components.notify.slack -slacker==0.9.65 +slacker==0.11.0 # homeassistant.components.sleepiq sleepyq==0.6 @@ -1565,7 +1582,7 @@ upsmychoice==1.0.6 uscisstatus==0.1.1 # homeassistant.components.camera.uvc -uvcclient==0.10.1 +uvcclient==0.11.0 # homeassistant.components.climate.venstar venstarcolortouch==0.6 @@ -1574,7 +1591,7 @@ venstarcolortouch==0.6 volkszaehler==0.1.2 # homeassistant.components.volvooncall -volvooncall==0.4.0 +volvooncall==0.7.11 # homeassistant.components.verisure vsure==1.5.2 @@ -1601,7 +1618,7 @@ warrant==0.6.1 watchdog==0.8.3 # homeassistant.components.waterfurnace -waterfurnace==0.7.0 +waterfurnace==1.0.0 # homeassistant.components.media_player.gpmdp websocket-client==0.37.0 @@ -1612,6 +1629,9 @@ websockets==6.0 # homeassistant.components.wirelesstag wirelesstagpy==0.4.0 +# homeassistant.components.wunderlist +wunderpy2==0.1.6 + # homeassistant.components.zigbee xbee-helper==0.0.7 @@ -1633,7 +1653,7 @@ xmltodict==0.11.0 yahooweather==0.10 # homeassistant.components.alarm_control_panel.yale_smart_alarm -yalesmartalarmclient==0.1.4 +yalesmartalarmclient==0.1.5 # homeassistant.components.light.yeelight yeelight==0.4.3 @@ -1642,7 +1662,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.11.07 +youtube_dl==2018.11.23 # homeassistant.components.light.zengge zengge==0.2 diff --git a/requirements_docs.txt b/requirements_docs.txt index 16c861a75fc4f3..7fd779d423134d 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.8.1 -sphinx-autodoc-typehints==1.5.0 +Sphinx==1.8.2 +sphinx-autodoc-typehints==1.5.1 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index 204bc67b0867b6..8d761c1e614ba2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,6 +12,6 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 -pytest-timeout==1.3.2 -pytest==4.0.0 +pytest-timeout==1.3.3 +pytest==4.0.1 requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f602c04bd79832..e3ad8015a4c72a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -13,8 +13,8 @@ pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.0 pytest-sugar==0.9.2 -pytest-timeout==1.3.2 -pytest==4.0.0 +pytest-timeout==1.3.3 +pytest==4.0.1 requests_mock==1.5.2 @@ -58,12 +58,15 @@ defusedxml==0.5.0 # homeassistant.components.sensor.dsmr dsmr_parser==0.12 +# homeassistant.components.sensor.entur_public_transport +enturclient==0.1.0 + # homeassistant.components.sensor.season ephem==3.7.6.0 # homeassistant.components.evohome # homeassistant.components.climate.honeywell -evohomeclient==0.2.7 +evohomeclient==0.2.8 # homeassistant.components.feedreader feedparser==5.2.1 @@ -76,6 +79,7 @@ gTTS-token==1.1.3 # homeassistant.components.geo_location.geo_json_events # homeassistant.components.geo_location.nsw_rural_fire_service_feed +# homeassistant.components.geo_location.usgs_earthquakes_feed geojson_client==0.3 # homeassistant.components.sensor.geo_rss_events @@ -97,14 +101,17 @@ hdate==0.7.5 holidays==0.9.8 # homeassistant.components.frontend -home-assistant-frontend==20181121.1 +home-assistant-frontend==20181211.0 # homeassistant.components.homematicip_cloud homematicip==0.9.8 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb -influxdb==5.0.0 +influxdb==5.2.0 + +# homeassistant.components.verisure +jsonpath==0.75 # homeassistant.components.dyson libpurecoollink==0.4.2 @@ -162,7 +169,7 @@ pydeconz==47 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.52 +pyhomematic==0.1.53 # homeassistant.components.litejet pylitejet==0.1 @@ -198,6 +205,9 @@ python-forecastio==1.4.0 # homeassistant.components.nest python-nest==4.0.5 +# homeassistant.components.sensor.awair +python_awair==0.0.3 + # homeassistant.components.sensor.whois pythonwhois==2.4.3 @@ -214,7 +224,7 @@ pywebpush==1.6.0 regenmaschine==1.0.7 # homeassistant.components.python_script -restrictedpython==4.0b6 +restrictedpython==4.0b7 # homeassistant.components.rflink rflink==0.0.37 @@ -226,7 +236,7 @@ ring_doorbell==0.2.2 rxv==0.5.1 # homeassistant.components.simplisafe -simplisafe-python==3.1.13 +simplisafe-python==3.1.14 # homeassistant.components.sleepiq sleepyq==0.6 @@ -248,7 +258,10 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.camera.uvc -uvcclient==0.10.1 +uvcclient==0.11.0 + +# homeassistant.components.verisure +vsure==1.5.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 76a9e05de3397c..82dab374e42ca0 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ 'coinmarketcap', 'defusedxml', 'dsmr_parser', + 'enturclient', 'ephem', 'evohomeclient', 'feedparser', @@ -63,6 +64,7 @@ 'home-assistant-frontend', 'homematicip', 'influxdb', + 'jsonpath', 'libpurecoollink', 'libsoundtouch', 'luftdaten', @@ -91,6 +93,7 @@ 'pyspcwebgw', 'python-forecastio', 'python-nest', + 'python_awair', 'pytradfri\\[async\\]', 'pyunifi', 'pyupnp-async', @@ -108,6 +111,7 @@ 'srpenergy', 'statsd', 'uvcclient', + 'vsure', 'warrant', 'pythonwhois', 'wakeonlan', diff --git a/setup.py b/setup.py index 49147afdd705fa..f4da5411ed58b6 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ 'attrs==18.2.0', 'bcrypt==3.1.4', 'certifi>=2018.04.16', + # Dec 5, 2018: Idna released 2.8, requests caps idna at <2.8, CI fails + 'idna==2.7', 'jinja2>=2.10', 'PyJWT==1.6.4', # PyJWT has loose dependency. We want the latest one. @@ -46,7 +48,7 @@ 'pytz>=2018.04', 'pyyaml>=3.13,<4', 'requests==2.20.1', - 'ruamel.yaml==0.15.78', + 'ruamel.yaml==0.15.80', 'voluptuous==0.11.5', 'voluptuous-serialize==2.0.0', ] diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py index ffe0b103fc955f..748b5507824681 100644 --- a/tests/auth/mfa_modules/test_notify.py +++ b/tests/auth/mfa_modules/test_notify.py @@ -61,6 +61,7 @@ async def test_validating_mfa_counter(hass): 'counter': 0, 'notify_service': 'dummy', }) + async_mock_service(hass, 'notify', 'dummy') assert notify_auth_module._user_settings notify_setting = list(notify_auth_module._user_settings.values())[0] @@ -389,9 +390,8 @@ async def test_not_raise_exception_when_service_not_exist(hass): 'username': 'test-user', 'password': 'test-pass', }) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'mfa' - assert result['data_schema'].schema.get('code') == str + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'unknown_error' # wait service call finished await hass.async_block_till_done() diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 40de5ca73343bf..1fd70668f8b67f 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -4,12 +4,16 @@ from homeassistant.auth.permissions.entities import ( compile_entities, ENTITY_POLICY_SCHEMA) +from homeassistant.auth.permissions.models import PermissionLookup +from homeassistant.helpers.entity_registry import RegistryEntry + +from tests.common import mock_registry def test_entities_none(): """Test entity ID policy.""" policy = None - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is False @@ -17,7 +21,7 @@ def test_entities_empty(): """Test entity ID policy.""" policy = {} ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is False @@ -32,7 +36,7 @@ def test_entities_true(): """Test entity ID policy.""" policy = True ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -42,7 +46,7 @@ def test_entities_domains_true(): 'domains': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -54,7 +58,7 @@ def test_entities_domains_domain_true(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('switch.kitchen', 'read') is False @@ -76,7 +80,7 @@ def test_entities_entity_ids_true(): 'entity_ids': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True @@ -97,7 +101,7 @@ def test_entities_entity_ids_entity_id_true(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('switch.kitchen', 'read') is False @@ -123,7 +127,7 @@ def test_entities_control_only(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is False assert compiled('light.kitchen', 'edit') is False @@ -140,7 +144,7 @@ def test_entities_read_control(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is True assert compiled('light.kitchen', 'edit') is False @@ -152,7 +156,7 @@ def test_entities_all_allow(): 'all': True } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is True assert compiled('switch.kitchen', 'read') is True @@ -166,7 +170,7 @@ def test_entities_all_read(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) assert compiled('light.kitchen', 'read') is True assert compiled('light.kitchen', 'control') is False assert compiled('switch.kitchen', 'read') is True @@ -180,8 +184,40 @@ def test_entities_all_control(): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy) + compiled = compile_entities(policy, None) 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 + + +def test_entities_device_id_boolean(hass): + """Test entity ID policy applying control on device id.""" + registry = mock_registry(hass, { + 'test_domain.allowed': RegistryEntry( + entity_id='test_domain.allowed', + unique_id='1234', + platform='test_platform', + device_id='mock-allowed-dev-id' + ), + 'test_domain.not_allowed': RegistryEntry( + entity_id='test_domain.not_allowed', + unique_id='5678', + platform='test_platform', + device_id='mock-not-allowed-dev-id' + ), + }) + + policy = { + 'device_ids': { + 'mock-allowed-dev-id': { + 'read': True, + } + } + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, PermissionLookup(registry)) + assert compiled('test_domain.allowed', 'read') is True + assert compiled('test_domain.allowed', 'control') is False + assert compiled('test_domain.not_allowed', 'read') is False + assert compiled('test_domain.not_allowed', 'control') is False diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py index ba6fe21414632c..f6a68f0865a0cf 100644 --- a/tests/auth/permissions/test_system_policies.py +++ b/tests/auth/permissions/test_system_policies.py @@ -8,7 +8,7 @@ def test_admin_policy(): # Make sure it's valid POLICY_SCHEMA(system_policies.ADMIN_POLICY) - perms = PolicyPermissions(system_policies.ADMIN_POLICY) + perms = PolicyPermissions(system_policies.ADMIN_POLICY, None) assert perms.check_entity('light.kitchen', 'read') assert perms.check_entity('light.kitchen', 'control') assert perms.check_entity('light.kitchen', 'edit') @@ -19,7 +19,7 @@ def test_read_only_policy(): # Make sure it's valid POLICY_SCHEMA(system_policies.READ_ONLY_POLICY) - perms = PolicyPermissions(system_policies.READ_ONLY_POLICY) + perms = PolicyPermissions(system_policies.READ_ONLY_POLICY, None) 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/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index 84beb8cdd3f477..d3fa27b9f5bccf 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,7 +1,6 @@ """Test the Home Assistant local auth provider.""" from unittest.mock import Mock -import base64 import pytest import voluptuous as vol @@ -134,91 +133,3 @@ async def test_new_users_populate_values(hass, data): user = await manager.async_get_or_create_user(credentials) assert user.name == 'hello' assert user.is_active - - -async def test_new_hashes_are_bcrypt(data, hass): - """Test that newly created hashes are using bcrypt.""" - data.add_auth('newuser', 'newpass') - found = None - for user in data.users: - if user['username'] == 'newuser': - found = user - assert found is not None - user_hash = base64.b64decode(found['password']) - assert (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')) - - -async def test_pbkdf2_to_bcrypt_hash_upgrade(hass_storage, hass): - """Test migrating user from pbkdf2 hash to bcrypt hash.""" - hass_storage[hass_auth.STORAGE_KEY] = { - 'version': hass_auth.STORAGE_VERSION, - 'key': hass_auth.STORAGE_KEY, - 'data': { - 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' - '0b08e6a3ea', - 'users': [ - { - 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' - 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', - 'username': 'legacyuser' - } - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - - # verify the correct (pbkdf2) password successfuly authenticates the user - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'beer') - - # ...and that the hashes are now bcrypt hashes - user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - assert (user_hash.startswith(b'$2a$') - or user_hash.startswith(b'$2b$') - or user_hash.startswith(b'$2x$') - or user_hash.startswith(b'$2y$')) - - -async def test_pbkdf2_to_bcrypt_hash_upgrade_with_incorrect_pass(hass_storage, - hass): - """Test migrating user from pbkdf2 hash to bcrypt hash.""" - hass_storage[hass_auth.STORAGE_KEY] = { - 'version': hass_auth.STORAGE_VERSION, - 'key': hass_auth.STORAGE_KEY, - 'data': { - 'salt': '09c52f0b120eaa7dea5f73f9a9b985f3d493b30a08f3f2945ef613' - '0b08e6a3ea', - 'users': [ - { - 'password': 'L5PAbehB8LAQI2Ixu+d+PDNJKmljqLnBcYWYw35onC/8D' - 'BM1SpvT6A8ZFael5+deCt+s+43J08IcztnguouHSw==', - 'username': 'legacyuser' - } - ] - }, - } - data = hass_auth.Data(hass) - await data.async_load() - - orig_user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - - # Make sure invalid legacy passwords fail - with pytest.raises(hass_auth.InvalidAuth): - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'wine') - - # Make sure we don't change the password/hash when password is incorrect - with pytest.raises(hass_auth.InvalidAuth): - await hass.async_add_executor_job( - data.validate_login, 'legacyuser', 'wine') - - same_user_hash = base64.b64decode( - hass_storage[hass_auth.STORAGE_KEY]['data']['users'][0]['password']) - - assert orig_user_hash == same_user_hash diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py index b02111e8d02aa4..329124bc979cca 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -5,7 +5,12 @@ def test_owner_fetching_owner_permissions(): """Test we fetch the owner permissions for an owner user.""" group = models.Group(name="Test Group", policy={}) - owner = models.User(name="Test User", groups=[group], is_owner=True) + owner = models.User( + name="Test User", + perm_lookup=None, + groups=[group], + is_owner=True + ) assert owner.permissions is permissions.OwnerPermissions @@ -25,7 +30,11 @@ def test_permissions_merged(): } } }) - user = models.User(name="Test User", groups=[group, group2]) + user = models.User( + name="Test User", + perm_lookup=None, + groups=[group, group2] + ) # Make sure we cache instance assert user.permissions is user.permissions diff --git a/tests/common.py b/tests/common.py index d5056e220f0156..d7b28b3039a271 100644 --- a/tests/common.py +++ b/tests/common.py @@ -114,8 +114,7 @@ def stop_hass(): # pylint: disable=protected-access -@asyncio.coroutine -def async_test_home_assistant(loop): +async def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) hass.config.async_load = Mock() @@ -168,13 +167,12 @@ def async_create_task(coroutine): # Mock async_start orig_start = hass.async_start - @asyncio.coroutine - def mock_async_start(): + async def mock_async_start(): """Start the mocking.""" # We only mock time during tests and we want to track tasks with patch('homeassistant.core._async_create_timer'), \ patch.object(hass, 'async_stop_track_tasks'): - yield from orig_start() + await orig_start() hass.async_start = mock_async_start @@ -386,6 +384,7 @@ def __init__(self, id=None, is_owner=False, is_active=True, 'name': name, 'system_generated': system_generated, 'groups': groups or [], + 'perm_lookup': None, } if id is not None: kwargs['id'] = id @@ -403,7 +402,8 @@ def add_to_auth_manager(self, auth_mgr): def mock_policy(self, policy): """Mock a policy for a user.""" - self._permissions = auth_permissions.PolicyPermissions(policy) + self._permissions = auth_permissions.PolicyPermissions( + policy, self.perm_lookup) async def register_auth_provider(hass, config): @@ -715,14 +715,22 @@ def init_recorder_component(hass, add_config=None): def mock_restore_cache(hass, states): """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_CACHE - hass.data[key] = { - state.entity_id: state for state in states} - _LOGGER.debug('Restore cache: %s', hass.data[key]) - assert len(hass.data[key]) == len(states), \ + key = restore_state.DATA_RESTORE_STATE_TASK + data = restore_state.RestoreStateData(hass) + now = date_util.utcnow() + + data.last_states = { + state.entity_id: restore_state.StoredState(state, now) + for state in states} + _LOGGER.debug('Restore cache: %s', data.last_states) + assert len(data.last_states) == len(states), \ "Duplicate entity_id? {}".format(states) - hass.state = ha.CoreState.starting - mock_component(hass, recorder.DOMAIN) + + async def get_restore_state_data() -> restore_state.RestoreStateData: + return data + + # Patch the singleton task in hass.data to return our new RestoreStateData + hass.data[key] = hass.async_create_task(get_restore_state_data()) class MockDependency: @@ -846,9 +854,10 @@ async def mock_async_load(store): def mock_write_data(store, path, data_to_write): """Mock version of write data.""" - # To ensure that the data can be serialized _LOGGER.info('Writing data to %s: %s', store.key, data_to_write) - data[store.key] = json.loads(json.dumps(data_to_write)) + # To ensure that the data can be serialized + data[store.key] = json.loads(json.dumps( + data_to_write, cls=store._encoder)) with patch('homeassistant.helpers.storage.Store._async_load', side_effect=mock_async_load, autospec=True), \ diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py index 64616718125864..24f1b00ee9052a 100644 --- a/tests/components/alarm_control_panel/test_mqtt.py +++ b/tests/components/alarm_control_panel/test_mqtt.py @@ -271,3 +271,42 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): state = hass.states.get('alarm_control_panel.beer') assert state is None + + +async def test_discovery_update_alarm(hass, mqtt_mock, caplog): + """Test removal of discovered alarm_control_panel.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, + 'homeassistant/alarm_control_panel/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('alarm_control_panel.milk') + assert state is None diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index d7871e82afc58c..592ec5854211dd 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -21,7 +21,7 @@ @pytest.fixture -def alexa_client(loop, hass, aiohttp_client): +def alexa_client(loop, hass, hass_client): """Initialize a Home Assistant server for testing this module.""" @callback def mock_service(call): @@ -49,7 +49,7 @@ def mock_service(call): }, } })) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) def _flash_briefing_req(client, briefing_id): diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 3cfb8068177f7e..ddf66d1c6177b8 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1354,6 +1354,25 @@ async def test_report_colored_light_state(hass): }) +async def test_report_colored_temp_light_state(hass): + """Test ColorTemperatureController reports color temp correctly.""" + hass.states.async_set( + 'light.test_on', 'on', {'friendly_name': "Test light On", + 'color_temp': 240, + 'supported_features': 2}) + hass.states.async_set( + 'light.test_off', 'off', {'friendly_name': "Test light Off", + 'supported_features': 2}) + + properties = await reported_properties(hass, 'light.test_on') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 4166) + + properties = await reported_properties(hass, 'light.test_off') + properties.assert_equal('Alexa.ColorTemperatureController', + 'colorTemperatureInKelvin', 0) + + async def reported_properties(hass, endpoint): """Use ReportState to get properties and return them. diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index e28f7be43413ad..1193526d2be445 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -114,8 +114,7 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): user.credentials.append(credential) assert len(user.credentials) == 1 - with patch('homeassistant.auth.AuthManager.active', return_value=True): - client = await hass_ws_client(hass, hass_access_token) + client = await hass_ws_client(hass, hass_access_token) await client.send_json({ 'id': 5, diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 28d4c0979c45d3..a01b48b9190de5 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -1,15 +1,16 @@ """The tests for the automation component.""" import asyncio from datetime import timedelta -from unittest.mock import patch +from unittest.mock import patch, Mock import pytest -from homeassistant.core import State, CoreState +from homeassistant.core import State, CoreState, Context from homeassistant.setup import async_setup_component import homeassistant.components.automation as automation from homeassistant.const import ( - ATTR_ENTITY_ID, STATE_ON, STATE_OFF, EVENT_HOMEASSISTANT_START) + ATTR_NAME, ATTR_ENTITY_ID, STATE_ON, STATE_OFF, + EVENT_HOMEASSISTANT_START, EVENT_AUTOMATION_TRIGGERED) from homeassistant.exceptions import HomeAssistantError import homeassistant.util.dt as dt_util @@ -342,6 +343,66 @@ async def test_automation_calling_two_actions(hass, calls): assert calls[1].data['position'] == 1 +async def test_shared_context(hass, calls): + """Test that the shared context is passed down the chain.""" + assert await async_setup_component(hass, automation.DOMAIN, { + automation.DOMAIN: [ + { + 'alias': 'hello', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event', + }, + 'action': {'event': 'test_event2'} + }, + { + 'alias': 'bye', + 'trigger': { + 'platform': 'event', + 'event_type': 'test_event2', + }, + 'action': { + 'service': 'test.automation', + } + } + ] + }) + + context = Context() + automation_mock = Mock() + event_mock = Mock() + + hass.bus.async_listen('test_event2', automation_mock) + hass.bus.async_listen(EVENT_AUTOMATION_TRIGGERED, event_mock) + hass.bus.async_fire('test_event', context=context) + await hass.async_block_till_done() + + # Ensure events was fired + assert automation_mock.call_count == 1 + assert event_mock.call_count == 2 + + # Ensure context carries through the event + args, kwargs = automation_mock.call_args + assert args[0].context == context + + for call in event_mock.call_args_list: + args, kwargs = call + assert args[0].context == context + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None + + # Ensure the automation state shares the same context + state = hass.states.get('automation.hello') + assert state is not None + assert state.context == context + + # Ensure the service call from the second automation + # shares the same context + assert len(calls) == 1 + assert calls[0].context == context + + async def test_services(hass, calls): """Test the automation services for turning entities on/off.""" entity_id = 'automation.hello' diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index a5f6a751b46f2e..ff475376587efe 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,11 +5,11 @@ import homeassistant.util.dt as dt_util -async def test_events_http_api(hass, aiohttp_client): +async def test_events_http_api(hass, hass_client): """Test the calendar demo view.""" await async_setup_component(hass, 'calendar', {'calendar': {'platform': 'demo'}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/calendars/calendar.calendar_2') assert response.status == 400 @@ -24,11 +24,11 @@ async def test_events_http_api(hass, aiohttp_client): assert events[0]['title'] == 'Future Event' -async def test_calendars_http_api(hass, aiohttp_client): +async def test_calendars_http_api(hass, hass_client): """Test the calendar demo view.""" await async_setup_component(hass, 'calendar', {'calendar': {'platform': 'demo'}}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get('/api/calendars') assert response.status == 200 data = await response.json() diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py index b981fced32020a..843bda0656c434 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -7,7 +7,7 @@ @asyncio.coroutine -def test_fetching_url(aioclient_mock, hass, aiohttp_client): +def test_fetching_url(aioclient_mock, hass, hass_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com', text='hello world') @@ -20,7 +20,7 @@ def test_fetching_url(aioclient_mock, hass, aiohttp_client): 'password': 'pass' }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -34,7 +34,7 @@ def test_fetching_url(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): +def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client): """Test that it fetches the given url when ssl verify is off.""" aioclient_mock.get('https://example.com', text='hello world') @@ -48,7 +48,7 @@ def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): 'verify_ssl': 'false', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -56,7 +56,7 @@ def test_fetching_without_verify_ssl(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): +def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client): """Test that it fetches the given url when ssl verify is explicitly on.""" aioclient_mock.get('https://example.com', text='hello world') @@ -70,7 +70,7 @@ def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): 'verify_ssl': 'true', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -78,7 +78,7 @@ def test_fetching_url_with_verify_ssl(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_limit_refetch(aioclient_mock, hass, aiohttp_client): +def test_limit_refetch(aioclient_mock, hass, hass_client): """Test that it fetches the given url.""" aioclient_mock.get('http://example.com/5a', text='hello world') aioclient_mock.get('http://example.com/10a', text='hello world') @@ -94,7 +94,7 @@ def test_limit_refetch(aioclient_mock, hass, aiohttp_client): 'limit_refetch_to_url_change': True, }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/camera_proxy/camera.config_test') @@ -139,7 +139,7 @@ def test_limit_refetch(aioclient_mock, hass, aiohttp_client): @asyncio.coroutine -def test_camera_content_type(aioclient_mock, hass, aiohttp_client): +def test_camera_content_type(aioclient_mock, hass, hass_client): """Test generic camera with custom content_type.""" svg_image = '' urlsvg = 'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg' @@ -158,7 +158,7 @@ def test_camera_content_type(aioclient_mock, hass, aiohttp_client): yield from async_setup_component(hass, 'camera', { 'camera': [cam_config_svg, cam_config_normal]}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp_1 = yield from client.get('/api/camera_proxy/camera.config_test_svg') assert aioclient_mock.call_count == 1 diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 0a57512aabd557..f2dbb2941360fe 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -11,7 +11,7 @@ @asyncio.coroutine -def test_loading_file(hass, aiohttp_client): +def test_loading_file(hass, hass_client): """Test that it loads image from disk.""" mock_registry(hass) @@ -24,7 +24,7 @@ def test_loading_file(hass, aiohttp_client): 'file_path': 'mock.file', }}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() m_open = mock.mock_open(read_data=b'hello') with mock.patch( @@ -56,7 +56,7 @@ def test_file_not_readable(hass, caplog): @asyncio.coroutine -def test_camera_content_type(hass, aiohttp_client): +def test_camera_content_type(hass, hass_client): """Test local_file camera content_type.""" cam_config_jpg = { 'name': 'test_jpg', @@ -83,7 +83,7 @@ def test_camera_content_type(hass, aiohttp_client): 'camera': [cam_config_jpg, cam_config_png, cam_config_svg, cam_config_noext]}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() image = 'hello' m_open = mock.mock_open(read_data=image.encode()) diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py index 6d9688c10e62ab..be1d24ce34fbb4 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -4,90 +4,51 @@ from datetime import timedelta from homeassistant import core as ha +from homeassistant.components import webhook from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from homeassistant.components.http.auth import setup_auth async def test_bad_posting(aioclient_mock, hass, aiohttp_client): """Test that posting to wrong api endpoint fails.""" + await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', 'name': 'config_test', - 'token': '12345678' - }}) - client = await aiohttp_client(hass.http.app) - - # missing file - resp = await client.post('/api/camera_push/camera.config_test') - assert resp.status == 400 - - # wrong entity - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.wrong', data=files) - assert resp.status == 404 - - -async def test_cases_with_no_auth(aioclient_mock, hass, aiohttp_client): - """Test cases where aiohttp_client is not auth.""" - await async_setup_component(hass, 'camera', { - 'camera': { - 'platform': 'push', - 'name': 'config_test', - 'token': '12345678' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() + assert hass.states.get('camera.config_test') is not None - setup_auth(hass.http.app, [], True, api_password=None) client = await aiohttp_client(hass.http.app) - # wrong token + # wrong webhook files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test?token=1234', - data=files) - assert resp.status == 401 - - # right token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', - data=files) - assert resp.status == 200 - - -async def test_no_auth_no_token(aioclient_mock, hass, aiohttp_client): - """Test cases where aiohttp_client is not auth.""" - await async_setup_component(hass, 'camera', { - 'camera': { - 'platform': 'push', - 'name': 'config_test', - }}) + resp = await client.post('/api/webhood/camera.wrong', data=files) + assert resp.status == 404 - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) + # missing file + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' - # no token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test', - data=files) - assert resp.status == 401 + resp = await client.post('/api/webhook/camera.config_test') + assert resp.status == 200 # webhooks always return 200 - # fake token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', - data=files) - assert resp.status == 401 + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' # no file supplied we are still idle async def test_posting_url(hass, aiohttp_client): """Test that posting to api endpoint works.""" + await async_setup_component(hass, webhook.DOMAIN, {}) await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'push', 'name': 'config_test', - 'token': '12345678' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() client = await aiohttp_client(hass.http.app) files = {'image': io.BytesIO(b'fake')} @@ -98,7 +59,7 @@ async def test_posting_url(hass, aiohttp_client): # post image resp = await client.post( - '/api/camera_push/camera.config_test?token=12345678', + '/api/webhook/camera.config_test', data=files) assert resp.status == 200 diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index b41cb9f865bb26..476e612eb06276 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -55,7 +55,12 @@ def mock_get_camera(uuid): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 123, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 123, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'id1', 'Front', 'bar'), mock.call(mock_remote.return_value, 'id2', 'Back', 'bar'), @@ -81,7 +86,12 @@ def test_setup_partial_config(self, mock_uvc, mock_remote): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 7080, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 7080, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'id1', 'Front', 'ubnt'), mock.call(mock_remote.return_value, 'id2', 'Back', 'ubnt'), @@ -107,7 +117,12 @@ def test_setup_partial_config_v31x(self, mock_uvc, mock_remote): assert setup_component(self.hass, 'camera', {'camera': config}) assert mock_remote.call_count == 1 - assert mock_remote.call_args == mock.call('foo', 7080, 'secret') + assert mock_remote.call_args == mock.call( + 'foo', + 7080, + 'secret', + ssl=False + ) mock_uvc.assert_has_calls([ mock.call(mock_remote.return_value, 'one', 'Front', 'ubnt'), mock.call(mock_remote.return_value, 'two', 'Back', 'ubnt'), diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py index 462939af23ad68..3a023916741c67 100644 --- a/tests/components/climate/test_demo.py +++ b/tests/components/climate/test_demo.py @@ -1,6 +1,9 @@ """The tests for the demo climate component.""" import unittest +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( METRIC_SYSTEM ) @@ -57,7 +60,8 @@ def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 21 == state.attributes.get('temperature') - common.set_temperature(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() assert 21 == state.attributes.get('temperature') @@ -99,9 +103,11 @@ def test_set_target_temp_range_bad_attr(self): assert state.attributes.get('temperature') is None assert 21.0 == state.attributes.get('target_temp_low') assert 24.0 == state.attributes.get('target_temp_high') - common.set_temperature(self.hass, temperature=None, - entity_id=ENTITY_ECOBEE, target_temp_low=None, - target_temp_high=None) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, temperature=None, + entity_id=ENTITY_ECOBEE, + target_temp_low=None, + target_temp_high=None) self.hass.block_till_done() state = self.hass.states.get(ENTITY_ECOBEE) assert state.attributes.get('temperature') is None @@ -112,7 +118,8 @@ def test_set_target_humidity_bad_attr(self): """Test setting the target humidity without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert 67 == state.attributes.get('humidity') - common.set_humidity(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_humidity(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert 67 == state.attributes.get('humidity') @@ -130,7 +137,8 @@ def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert "On High" == state.attributes.get('fan_mode') - common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "On High" == state.attributes.get('fan_mode') @@ -148,7 +156,8 @@ def test_set_swing_mode_bad_attr(self): """Test setting swing mode without required attribute.""" state = self.hass.states.get(ENTITY_CLIMATE) assert "Off" == state.attributes.get('swing_mode') - common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "Off" == state.attributes.get('swing_mode') @@ -170,7 +179,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "cool" == state.attributes.get('operation_mode') assert "cool" == state.state - common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "cool" == state.attributes.get('operation_mode') diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index 2e942c5988caa1..2aeb1228aba273 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -1,6 +1,9 @@ """The tests for the climate component.""" import asyncio +import pytest +import voluptuous as vol + from homeassistant.components.climate import SET_TEMPERATURE_SCHEMA from tests.common import async_mock_service @@ -14,12 +17,11 @@ def test_set_temp_schema_no_req(hass, caplog): calls = async_mock_service(hass, domain, service, schema) data = {'operation_mode': 'test', 'entity_id': ['climate.test_id']} - yield from hass.services.async_call(domain, service, data) + with pytest.raises(vol.Invalid): + yield from hass.services.async_call(domain, service, data) yield from hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text @asyncio.coroutine diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 61b481ed4db89b..7beb3887ae0c5c 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -2,6 +2,9 @@ import unittest import copy +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( METRIC_SYSTEM ) @@ -91,7 +94,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('operation_mode') assert "off" == state.state - common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('operation_mode') @@ -177,7 +181,8 @@ def test_set_fan_mode_bad_attr(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "low" == state.attributes.get('fan_mode') - common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_fan_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "low" == state.attributes.get('fan_mode') @@ -225,7 +230,8 @@ def test_set_swing_mode_bad_attr(self): state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('swing_mode') - common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) + with pytest.raises(vol.Invalid): + common.set_swing_mode(self.hass, None, ENTITY_CLIMATE) self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) assert "off" == state.attributes.get('swing_mode') @@ -684,3 +690,34 @@ async def test_discovery_removal_climate(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('climate.beer') assert state is None + + +async def test_discovery_update_climate(hass, mqtt_mock, caplog): + """Test removal of discovered climate.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer" }' + ) + data2 = ( + '{ "name": "Milk" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('climate.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('climate.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('climate.milk') + assert state is None diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py new file mode 100644 index 00000000000000..0ddb8ecce500d2 --- /dev/null +++ b/tests/components/cloud/test_cloud_api.py @@ -0,0 +1,33 @@ +"""Test cloud API.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.components.cloud import cloud_api + + +@pytest.fixture(autouse=True) +def mock_check_token(): + """Mock check token.""" + with patch('homeassistant.components.cloud.auth_api.' + 'check_token') as mock_check_token: + yield mock_check_token + + +async def test_create_cloudhook(hass, aioclient_mock): + """Test creating a cloudhook.""" + aioclient_mock.post('https://example.com/bla', json={ + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + }) + cloud = Mock( + hass=hass, + id_token='mock-id-token', + cloudhook_create_url='https://example.com/bla', + ) + resp = await cloud_api.async_create_cloudhook(cloud) + assert len(aioclient_mock.mock_calls) == 1 + assert await resp.json() == { + 'cloudhook_id': 'mock-webhook', + 'url': 'https://blabla' + } diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py new file mode 100644 index 00000000000000..9306a6c6ef304d --- /dev/null +++ b/tests/components/cloud/test_cloudhooks.py @@ -0,0 +1,70 @@ +"""Test cloud cloudhooks.""" +from unittest.mock import Mock + +import pytest + +from homeassistant.components.cloud import prefs, cloudhooks + +from tests.common import mock_coro + + +@pytest.fixture +def mock_cloudhooks(hass): + """Mock cloudhooks class.""" + cloud = Mock() + cloud.hass = hass + cloud.hass.async_add_executor_job = Mock(return_value=mock_coro()) + cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro())) + cloud.cloudhook_create_url = 'https://webhook-create.url' + cloud.prefs = prefs.CloudPreferences(hass) + hass.loop.run_until_complete(cloud.prefs.async_initialize()) + return cloudhooks.Cloudhooks(cloud) + + +async def test_enable(mock_cloudhooks, aioclient_mock): + """Test enabling cloudhooks.""" + aioclient_mock.post('https://webhook-create.url', json={ + 'cloudhook_id': 'mock-cloud-id', + 'url': 'https://hooks.nabu.casa/ZXCZCXZ', + }) + + hook = { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + + assert hook == await mock_cloudhooks.async_create('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == { + 'mock-webhook-id': hook + } + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': ['mock-cloud-id'] + } + + +async def test_disable(mock_cloudhooks): + """Test disabling cloudhooks.""" + mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = { + 'mock-webhook-id': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + } + + await mock_cloudhooks.async_delete('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == {} + + publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls + assert len(publish_calls) == 1 + assert publish_calls[0][1][0] == 'webhook-register' + assert publish_calls[0][1][1] == { + 'cloudhook_ids': [] + } diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 4abf5b8501d9a4..84d35f4bdd834d 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -52,10 +52,10 @@ def setup_api(hass): @pytest.fixture -def cloud_client(hass, aiohttp_client): +def cloud_client(hass, hass_client): """Fixture that can fetch from the cloud client.""" with patch('homeassistant.components.cloud.Cloud.write_user_info'): - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) @pytest.fixture @@ -527,3 +527,45 @@ async def test_websocket_update_preferences(hass, hass_ws_client, assert not setup_api[PREF_ENABLE_GOOGLE] assert not setup_api[PREF_ENABLE_ALEXA] assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] + + +async def test_enabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to enable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_create', return_value=mock_coro()) as mock_enable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/create', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_enable.mock_calls) == 1 + assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id' + + +async def test_disabling_webhook(hass, hass_ws_client, setup_api): + """Test we call right code to disable webhooks.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + client = await hass_ws_client(hass) + with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' + '.async_delete', return_value=mock_coro()) as mock_disable: + await client.send_json({ + 'id': 5, + 'type': 'cloud/cloudhook/delete', + 'webhook_id': 'mock-webhook-id', + }) + response = await client.receive_json() + assert response['success'] + + assert len(mock_disable.mock_calls) == 1 + assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 44d56566f7566d..baf6747aead78b 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -30,7 +30,8 @@ def test_constructor_loads_info_from_constant(): 'region': 'test-region', 'relayer': 'test-relayer', 'google_actions_sync_url': 'test-google_actions_sync_url', - 'subscription_info_url': 'test-subscription-info-url' + 'subscription_info_url': 'test-subscription-info-url', + 'cloudhook_create_url': 'test-cloudhook_create_url', } }): result = yield from cloud.async_setup(hass, { @@ -46,6 +47,7 @@ def test_constructor_loads_info_from_constant(): assert cl.relayer == 'test-relayer' assert cl.google_actions_sync_url == 'test-google_actions_sync_url' assert cl.subscription_info_url == 'test-subscription-info-url' + assert cl.cloudhook_create_url == 'test-cloudhook_create_url' @asyncio.coroutine diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index c900fc3a7a85de..2133a803aef7b2 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -2,7 +2,7 @@ import asyncio from unittest.mock import patch, MagicMock, PropertyMock -from aiohttp import WSMsgType, client_exceptions +from aiohttp import WSMsgType, client_exceptions, web import pytest from homeassistant.setup import async_setup_component @@ -406,3 +406,96 @@ async def test_refresh_token_expired(hass): assert len(mock_check_token.mock_calls) == 1 assert len(mock_create.mock_calls) == 1 + + +async def test_webhook_msg(hass): + """Test webhook msg.""" + cloud = Cloud(hass, MODE_DEV, None, None) + await cloud.prefs.async_initialize() + await cloud.prefs.async_update(cloudhooks={ + 'hello': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id' + } + }) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({'from': 'handler'}) + + hass.components.webhook.async_register( + 'test', 'Test', 'mock-webhook-id', handler) + + response = await iot.async_handle_webhook(hass, cloud, { + 'cloudhook_id': 'mock-cloud-id', + 'body': '{"hello": "world"}', + 'headers': { + 'content-type': 'application/json' + }, + 'method': 'POST', + 'query': None, + }) + + assert response == { + 'status': 200, + 'body': '{"from": "handler"}', + 'headers': { + 'Content-Type': 'application/json' + } + } + + assert len(received) == 1 + assert await received[0].json() == { + 'hello': 'world' + } + + +async def test_send_message_not_connected(mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + + with pytest.raises(iot.NotConnected): + await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) + + +async def test_send_message_no_answer(mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + cloud_iot.state = iot.STATE_CONNECTED + cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) + + await cloud_iot.async_send_message('webhook', {'msg': 'yo'}, + expect_answer=False) + assert not cloud_iot._response_handler + assert len(cloud_iot.client.send_json.mock_calls) == 1 + msg = cloud_iot.client.send_json.mock_calls[0][1][0] + assert msg['handler'] == 'webhook' + assert msg['payload'] == {'msg': 'yo'} + + +async def test_send_message_answer(loop, mock_cloud): + """Test sending a message that expects no answer.""" + cloud_iot = iot.CloudIoT(mock_cloud) + cloud_iot.state = iot.STATE_CONNECTED + cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) + + uuid = 5 + + with patch('homeassistant.components.cloud.iot.uuid.uuid4', + return_value=MagicMock(hex=uuid)): + send_task = loop.create_task(cloud_iot.async_send_message( + 'webhook', {'msg': 'yo'})) + await asyncio.sleep(0) + + assert len(cloud_iot.client.send_json.mock_calls) == 1 + assert len(cloud_iot._response_handler) == 1 + msg = cloud_iot.client.send_json.mock_calls[0][1][0] + assert msg['handler'] == 'webhook' + assert msg['payload'] == {'msg': 'yo'} + + cloud_iot._response_handler[uuid].set_result({'response': True}) + response = await send_task + assert response == {'response': True} diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index f7e348e847665e..b5e0a8c91977c1 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -1,6 +1,4 @@ """Test config entries API.""" -from unittest.mock import PropertyMock, patch - import pytest from homeassistant.auth import models as auth_models @@ -9,14 +7,6 @@ from tests.common import MockGroup, MockUser, CLIENT_ID -@pytest.fixture(autouse=True) -def auth_active(hass): - """Mock that auth is active.""" - with patch('homeassistant.auth.AuthManager.active', - PropertyMock(return_value=True)): - yield - - @pytest.fixture(autouse=True) def setup_config(hass, aiohttp_client): """Fixture that sets up the auth provider homeassistant module.""" @@ -37,7 +27,7 @@ async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): assert result['error']['code'] == 'unauthorized' -async def test_list(hass, hass_ws_client): +async def test_list(hass, hass_ws_client, hass_admin_user): """Test get users.""" group = MockGroup().add_to_hass(hass) @@ -80,8 +70,17 @@ async def test_list(hass, hass_ws_client): result = await client.receive_json() assert result['success'], result data = result['result'] - assert len(data) == 3 + assert len(data) == 4 assert data[0] == { + 'id': hass_admin_user.id, + 'name': 'Mock User', + 'is_owner': False, + 'is_active': True, + 'system_generated': False, + 'group_ids': [group.id for group in hass_admin_user.groups], + 'credentials': [] + } + assert data[1] == { 'id': owner.id, 'name': 'Test Owner', 'is_owner': True, @@ -90,7 +89,7 @@ async def test_list(hass, hass_ws_client): 'group_ids': [group.id for group in owner.groups], 'credentials': [{'type': 'homeassistant'}] } - assert data[1] == { + assert data[2] == { 'id': system.id, 'name': 'Test Hass.io', 'is_owner': False, @@ -99,7 +98,7 @@ async def test_list(hass, hass_ws_client): 'group_ids': [], 'credentials': [], } - assert data[2] == { + assert data[3] == { 'id': inactive.id, 'name': 'Inactive User', 'is_owner': False, diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index 2c888dd2dd25d7..f97559a224f66f 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -6,12 +6,12 @@ from homeassistant.components import config -async def test_get_device_config(hass, aiohttp_client): +async def test_get_device_config(hass, hass_client): """Test getting device config.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() def mock_read(path): """Mock reading data.""" @@ -34,12 +34,12 @@ def mock_read(path): assert result == {'id': 'moon'} -async def test_update_device_config(hass, aiohttp_client): +async def test_update_device_config(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() orig_data = [ { @@ -83,12 +83,12 @@ def mock_write(path, data): assert written[0] == orig_data -async def test_bad_formatted_automations(hass, aiohttp_client): +async def test_bad_formatted_automations(hass, hass_client): """Test that we handle automations without ID.""" with patch.object(config, 'SECTIONS', ['automation']): await async_setup_component(hass, 'config', {}) - client = await aiohttp_client(hass.http.app) + client = await hass_client() orig_data = [ { diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 67d7eebbfecab7..0b36cc6bc874b2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -23,11 +23,11 @@ def mock_test_component(hass): @pytest.fixture -def client(hass, aiohttp_client): +def client(hass, hass_client): """Fixture that can interact with the config manager API.""" hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) hass.loop.run_until_complete(config_entries.async_setup(hass)) - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py index 5b52b3d571111c..4d9063d774bccd 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,14 +8,14 @@ @asyncio.coroutine -def test_validate_config_ok(hass, aiohttp_client): +def test_validate_config_ok(hass, hass_client): """Test checking config.""" with patch.object(config, 'SECTIONS', ['core']): yield from async_setup_component(hass, 'config', {}) yield from asyncio.sleep(0.1, loop=hass.loop) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() with patch( 'homeassistant.components.config.core.async_check_ha_config_file', diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py index 100a18618e69dc..7f81b65540fd35 100644 --- a/tests/components/config/test_customize.py +++ b/tests/components/config/test_customize.py @@ -9,12 +9,12 @@ @asyncio.coroutine -def test_get_entity(hass, aiohttp_client): +def test_get_entity(hass, hass_client): """Test getting entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() def mock_read(path): """Mock reading data.""" @@ -38,12 +38,12 @@ def mock_read(path): @asyncio.coroutine -def test_update_entity(hass, aiohttp_client): +def test_update_entity(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def mock_write(path, data): @asyncio.coroutine -def test_update_entity_invalid_key(hass, aiohttp_client): +def test_update_entity_invalid_key(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/customize/config/not_entity', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_entity_invalid_key(hass, aiohttp_client): @asyncio.coroutine -def test_update_entity_invalid_json(hass, aiohttp_client): +def test_update_entity_invalid_json(hass, hass_client): """Test updating entity.""" with patch.object(config, 'SECTIONS', ['customize']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/customize/config/hello.beer', data='not json') diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py index 06ba2ff1105014..52c72c60860b2e 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -11,12 +11,12 @@ @asyncio.coroutine -def test_get_device_config(hass, aiohttp_client): +def test_get_device_config(hass, hass_client): """Test getting device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() def mock_read(path): """Mock reading data.""" @@ -40,12 +40,12 @@ def mock_read(path): @asyncio.coroutine -def test_update_device_config(hass, aiohttp_client): +def test_update_device_config(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() orig_data = { 'hello.beer': { @@ -89,12 +89,12 @@ def mock_write(path, data): @asyncio.coroutine -def test_update_device_config_invalid_key(hass, aiohttp_client): +def test_update_device_config_invalid_key(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/not a slug', data=json.dumps({ @@ -105,12 +105,12 @@ def test_update_device_config_invalid_key(hass, aiohttp_client): @asyncio.coroutine -def test_update_device_config_invalid_data(hass, aiohttp_client): +def test_update_device_config_invalid_data(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/hello_beer', data=json.dumps({ @@ -121,12 +121,12 @@ def test_update_device_config_invalid_data(hass, aiohttp_client): @asyncio.coroutine -def test_update_device_config_invalid_json(hass, aiohttp_client): +def test_update_device_config_invalid_json(hass, hass_client): """Test updating device config.""" with patch.object(config, 'SECTIONS', ['group']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/group/config/hello_beer', data='not json') diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py index 85fbf0c2e5a899..547bb612ee46c6 100644 --- a/tests/components/config/test_hassbian.py +++ b/tests/components/config/test_hassbian.py @@ -34,13 +34,13 @@ def test_setup_check_env_works(hass, loop): @asyncio.coroutine -def test_get_suites(hass, aiohttp_client): +def test_get_suites(hass, hass_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.get('/api/config/hassbian/suites') assert resp.status == 200 result = yield from resp.json() @@ -53,13 +53,13 @@ def test_get_suites(hass, aiohttp_client): @asyncio.coroutine -def test_install_suite(hass, aiohttp_client): +def test_install_suite(hass, hass_client): """Test getting suites.""" with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ patch.object(config, 'SECTIONS', ['hassbian']): yield from async_setup_component(hass, 'config', {}) - client = yield from aiohttp_client(hass.http.app) + client = yield from hass_client() resp = yield from client.post( '/api/config/hassbian/suites/openzwave/install') assert resp.status == 200 diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 8aae5c0a28b92a..71ced80eac95b2 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -16,12 +16,12 @@ @pytest.fixture -def client(loop, hass, aiohttp_client): +def client(loop, hass, hass_client): """Client to communicate with Z-Wave config views.""" with patch.object(config, 'SECTIONS', ['zwave']): loop.run_until_complete(async_setup_component(hass, 'config', {})) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/conftest.py b/tests/components/conftest.py index d3cbdba63b4dc5..4903e8c645515c 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,14 +3,12 @@ import pytest -from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY -from homeassistant.auth.providers import legacy_api_password, homeassistant from homeassistant.setup import async_setup_component from homeassistant.components.websocket_api.http import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from tests.common import MockUser, CLIENT_ID, mock_coro +from tests.common import mock_coro @pytest.fixture(autouse=True) @@ -22,35 +20,15 @@ def prevent_io(): @pytest.fixture -def hass_ws_client(aiohttp_client): +def hass_ws_client(aiohttp_client, hass_access_token): """Websocket client fixture connected to websocket server.""" - async def create_client(hass, access_token=None): + async def create_client(hass, access_token=hass_access_token): """Create a websocket client.""" assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) - patches = [] - - if access_token is None: - patches.append(patch( - 'homeassistant.auth.AuthManager.active', return_value=False)) - patches.append(patch( - 'homeassistant.auth.AuthManager.support_legacy', - return_value=True)) - patches.append(patch( - 'homeassistant.components.websocket_api.auth.' - 'validate_password', return_value=True)) - else: - patches.append(patch( - 'homeassistant.auth.AuthManager.active', return_value=True)) - patches.append(patch( - 'homeassistant.components.http.auth.setup_auth')) - - for p in patches: - p.start() - - try: + with patch('homeassistant.components.http.auth.setup_auth'): websocket = await client.ws_connect(URL) auth_resp = await websocket.receive_json() assert auth_resp['type'] == TYPE_AUTH_REQUIRED @@ -69,76 +47,8 @@ async def create_client(hass, access_token=None): auth_ok = await websocket.receive_json() assert auth_ok['type'] == TYPE_AUTH_OK - finally: - for p in patches: - p.stop() - # wrap in client websocket.client = client return websocket return create_client - - -@pytest.fixture -def hass_access_token(hass, hass_admin_user): - """Return an access token to access Home Assistant.""" - refresh_token = hass.loop.run_until_complete( - hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) - yield hass.auth.async_create_access_token(refresh_token) - - -@pytest.fixture -def hass_owner_user(hass, local_auth): - """Return a Home Assistant admin user.""" - return MockUser(is_owner=True).add_to_hass(hass) - - -@pytest.fixture -def hass_admin_user(hass, local_auth): - """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, local_auth): - """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) - - -@pytest.fixture -def legacy_auth(hass): - """Load legacy API password provider.""" - prv = legacy_api_password.LegacyApiPasswordAuthProvider( - hass, hass.auth._store, { - 'type': 'legacy_api_password' - } - ) - hass.auth._providers[(prv.type, prv.id)] = prv - - -@pytest.fixture -def local_auth(hass): - """Load local auth provider.""" - prv = homeassistant.HassAuthProvider( - hass, hass.auth._store, { - 'type': 'homeassistant' - } - ) - hass.auth._providers[(prv.type, prv.id)] = prv - - -@pytest.fixture -def hass_client(hass, aiohttp_client, hass_access_token): - """Return an authenticated HTTP client.""" - async def auth_client(): - """Return an authenticated client.""" - return await aiohttp_client(hass.http.app, headers={ - 'Authorization': "Bearer {}".format(hass_access_token) - }) - - return auth_client diff --git a/tests/components/counter/common.py b/tests/components/counter/common.py index 36d09979d0d568..2fad06027fc4b2 100644 --- a/tests/components/counter/common.py +++ b/tests/components/counter/common.py @@ -10,12 +10,6 @@ from homeassistant.loader import bind_hass -@bind_hass -def increment(hass, entity_id): - """Increment a counter.""" - hass.add_job(async_increment, hass, entity_id) - - @callback @bind_hass def async_increment(hass, entity_id): @@ -24,12 +18,6 @@ def async_increment(hass, entity_id): DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id})) -@bind_hass -def decrement(hass, entity_id): - """Decrement a counter.""" - hass.add_job(async_decrement, hass, entity_id) - - @callback @bind_hass def async_decrement(hass, entity_id): @@ -38,12 +26,6 @@ def async_decrement(hass, entity_id): DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id})) -@bind_hass -def reset(hass, entity_id): - """Reset a counter.""" - hass.add_job(async_reset, hass, entity_id) - - @callback @bind_hass def async_reset(hass, entity_id): diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index c8411bf2fdeccf..97a39cdeb73b46 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -1,163 +1,153 @@ """The tests for the counter 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.counter import ( DOMAIN, CONF_INITIAL, CONF_RESTORE, CONF_STEP, CONF_NAME, CONF_ICON) from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME) -from tests.common import (get_test_home_assistant, mock_restore_cache) -from tests.components.counter.common import decrement, increment, reset +from tests.common import mock_restore_cache +from tests.components.counter.common import ( + async_decrement, async_increment, async_reset) _LOGGER = logging.getLogger(__name__) -class TestCounter(unittest.TestCase): - """Test the counter 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_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] - - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - - 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()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_INITIAL: 10, - CONF_RESTORE: False, - CONF_STEP: 5, - } +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_INITIAL: 10, + CONF_RESTORE: False, + CONF_STEP: 5, } } + } - assert setup_component(self.hass, 'counter', config) - self.hass.block_till_done() + assert await async_setup_component(hass, 'counter', config) + await hass.async_block_till_done() - _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids()) + _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids()) - assert count_start + 2 == len(self.hass.states.entity_ids()) - self.hass.block_till_done() + assert count_start + 2 == len(hass.states.async_entity_ids()) + await hass.async_block_till_done() - state_1 = self.hass.states.get('counter.test_1') - state_2 = self.hass.states.get('counter.test_2') + state_1 = hass.states.get('counter.test_1') + state_2 = hass.states.get('counter.test_2') - assert state_1 is not None - assert state_2 is not None + assert state_1 is not None + assert state_2 is not None - assert 0 == int(state_1.state) - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes + assert 0 == int(state_1.state) + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - assert 10 == int(state_2.state) - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert 10 == int(state_2.state) + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - def test_methods(self): - """Test increment, decrement, and reset methods.""" - config = { - DOMAIN: { - 'test_1': {}, - } + +async def test_methods(hass): + """Test increment, decrement, and reset methods.""" + config = { + DOMAIN: { + 'test_1': {}, } + } - assert setup_component(self.hass, 'counter', config) + assert await async_setup_component(hass, 'counter', config) - entity_id = 'counter.test_1' + entity_id = 'counter.test_1' - state = self.hass.states.get(entity_id) - assert 0 == int(state.state) + state = hass.states.get(entity_id) + assert 0 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 1 == int(state.state) + state = hass.states.get(entity_id) + assert 1 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 2 == int(state.state) + state = hass.states.get(entity_id) + assert 2 == int(state.state) - decrement(self.hass, entity_id) - self.hass.block_till_done() + async_decrement(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 1 == int(state.state) + state = hass.states.get(entity_id) + assert 1 == int(state.state) - reset(self.hass, entity_id) - self.hass.block_till_done() + async_reset(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 0 == int(state.state) + state = hass.states.get(entity_id) + assert 0 == int(state.state) - def test_methods_with_config(self): - """Test increment, decrement, and reset methods with configuration.""" - config = { - DOMAIN: { - 'test': { - CONF_NAME: 'Hello World', - CONF_INITIAL: 10, - CONF_STEP: 5, - } + +async def test_methods_with_config(hass): + """Test increment, decrement, and reset methods with configuration.""" + config = { + DOMAIN: { + 'test': { + CONF_NAME: 'Hello World', + CONF_INITIAL: 10, + CONF_STEP: 5, } } + } - assert setup_component(self.hass, 'counter', config) + assert await async_setup_component(hass, 'counter', config) - entity_id = 'counter.test' + entity_id = 'counter.test' - state = self.hass.states.get(entity_id) - assert 10 == int(state.state) + state = hass.states.get(entity_id) + assert 10 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) + state = hass.states.get(entity_id) + assert 15 == int(state.state) - increment(self.hass, entity_id) - self.hass.block_till_done() + async_increment(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 20 == int(state.state) + state = hass.states.get(entity_id) + assert 20 == int(state.state) - decrement(self.hass, entity_id) - self.hass.block_till_done() + async_decrement(hass, entity_id) + await hass.async_block_till_done() - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) + state = hass.states.get(entity_id) + assert 15 == int(state.state) @asyncio.coroutine diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 26204ce6ebdf74..df47a6caf48524 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -734,25 +734,29 @@ def test_tilt_position_altered_range(self): def test_find_percentage_in_range_defaults(self): """Test find percentage in range with default range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=100, position_closed=0, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 100, 'position_closed': 0, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_percentage_in_range(44) assert 44 == mqtt_cover.find_percentage_in_range(44, 'cover') @@ -760,25 +764,29 @@ def test_find_percentage_in_range_defaults(self): def test_find_percentage_in_range_altered(self): """Test find percentage in range with altered range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=180, position_closed=80, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 180, 'position_closed': 80, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 40 == mqtt_cover.find_percentage_in_range(120) assert 40 == mqtt_cover.find_percentage_in_range(120, 'cover') @@ -786,25 +794,29 @@ def test_find_percentage_in_range_altered(self): def test_find_percentage_in_range_defaults_inverted(self): """Test find percentage in range with default range but inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=0, position_closed=100, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 0, 'position_closed': 100, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 56 == mqtt_cover.find_percentage_in_range(44) assert 56 == mqtt_cover.find_percentage_in_range(44, 'cover') @@ -812,25 +824,29 @@ def test_find_percentage_in_range_defaults_inverted(self): def test_find_percentage_in_range_altered_inverted(self): """Test find percentage in range with altered range and inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=80, position_closed=180, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 80, 'position_closed': 180, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 60 == mqtt_cover.find_percentage_in_range(120) assert 60 == mqtt_cover.find_percentage_in_range(120, 'cover') @@ -838,25 +854,29 @@ def test_find_percentage_in_range_altered_inverted(self): def test_find_in_range_defaults(self): """Test find in range with default range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=100, position_closed=0, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 100, 'position_closed': 0, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_in_range_from_percent(44) assert 44 == mqtt_cover.find_in_range_from_percent(44, 'cover') @@ -864,25 +884,29 @@ def test_find_in_range_defaults(self): def test_find_in_range_altered(self): """Test find in range with altered range.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=180, position_closed=80, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=False, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 180, 'position_closed': 80, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': False, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 120 == mqtt_cover.find_in_range_from_percent(40) assert 120 == mqtt_cover.find_in_range_from_percent(40, 'cover') @@ -890,25 +914,29 @@ def test_find_in_range_altered(self): def test_find_in_range_defaults_inverted(self): """Test find in range with default range but inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=0, position_closed=100, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=100, tilt_closed_position=0, - tilt_min=0, tilt_max=100, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 0, 'position_closed': 100, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 100, 'tilt_closed_position': 0, + 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 44 == mqtt_cover.find_in_range_from_percent(56) assert 44 == mqtt_cover.find_in_range_from_percent(56, 'cover') @@ -916,25 +944,29 @@ def test_find_in_range_defaults_inverted(self): def test_find_in_range_altered_inverted(self): """Test find in range with altered range and inverted.""" mqtt_cover = MqttCover( - name='cover.test', - state_topic='state-topic', - get_position_topic=None, - command_topic='command-topic', - availability_topic=None, - tilt_command_topic='tilt-command-topic', - tilt_status_topic='tilt-status-topic', - qos=0, - retain=False, - state_open='OPEN', state_closed='CLOSE', - position_open=80, position_closed=180, - payload_open='OPEN', payload_close='CLOSE', payload_stop='STOP', - payload_available=None, payload_not_available=None, - optimistic=False, value_template=None, - tilt_open_position=180, tilt_closed_position=80, - tilt_min=80, tilt_max=180, tilt_optimistic=False, - tilt_invert=True, - set_position_topic=None, set_position_template=None, - unique_id=None, device_config=None, discovery_hash=None) + { + 'name': 'cover.test', + 'state_topic': 'state-topic', + 'get_position_topic': None, + 'command_topic': 'command-topic', + 'availability_topic': None, + 'tilt_command_topic': 'tilt-command-topic', + 'tilt_status_topic': 'tilt-status-topic', + 'qos': 0, + 'retain': False, + 'state_open': 'OPEN', 'state_closed': 'CLOSE', + 'position_open': 80, 'position_closed': 180, + 'payload_open': 'OPEN', 'payload_close': 'CLOSE', + 'payload_stop': 'STOP', + 'payload_available': None, 'payload_not_available': None, + 'optimistic': False, 'value_template': None, + 'tilt_open_position': 180, 'tilt_closed_position': 80, + 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False, + 'tilt_invert_state': True, + 'set_position_topic': None, 'set_position_template': None, + 'unique_id': None, 'device_config': None, + }, + None) assert 120 == mqtt_cover.find_in_range_from_percent(60) assert 120 == mqtt_cover.find_in_range_from_percent(60, 'cover') @@ -1032,6 +1064,38 @@ async def test_discovery_removal_cover(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_cover(hass, mqtt_mock, caplog): + """Test removal of discovered cover.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data1) + await hass.async_block_till_done() + state = hass.states.get('cover.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('cover.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('cover.milk') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one cover per id.""" await async_mock_mqtt_component(hass) diff --git a/tests/components/cover/test_template.py b/tests/components/cover/test_template.py index 3c820f1a0acd61..4d46882c9ea64f 100644 --- a/tests/components/cover/test_template.py +++ b/tests/components/cover/test_template.py @@ -1,9 +1,8 @@ """The tests the cover command line platform.""" import logging -import unittest +import pytest from homeassistant import setup -from homeassistant.core import callback from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN) from homeassistant.const import ( @@ -12,748 +11,748 @@ SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN) -from tests.common import ( - get_test_home_assistant, assert_setup_component) +from tests.common import assert_setup_component, async_mock_service _LOGGER = logging.getLogger(__name__) ENTITY_COVER = 'cover.test_template_cover' -class TestTemplateCover(unittest.TestCase): - """Test the cover command line platform.""" - - hass = None - calls = None - # pylint: disable=invalid-name - - def setup_method(self, method): - """Initialize services when tests are started.""" - self.hass = get_test_home_assistant() - self.calls = [] - - @callback - def record_call(service): - """Track function calls..""" - self.calls.append(service) - - self.hass.services.register('test', 'automation', record_call) - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_template_state_text(self): - """Test the state text of a template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') + + +async def test_template_state_text(hass, calls): + """Test the state text of a template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - state = self.hass.states.set('cover.test_state', STATE_CLOSED) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - def test_template_state_boolean(self): - """Test the value_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + state = hass.states.async_set('cover.test_state', STATE_CLOSED) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + +async def test_template_state_boolean(hass, calls): + """Test the value_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - def test_template_position(self): - """Test the position_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ states.cover.test.attributes.position }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + +async def test_template_position(hass, calls): + """Test the position_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ states.cover.test.attributes.position }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('cover.test', STATE_CLOSED) - self.hass.block_till_done() - - entity = self.hass.states.get('cover.test') - attrs = dict() - attrs['position'] = 42 - self.hass.states.set( - entity.entity_id, entity.state, - attributes=attrs) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - assert state.state == STATE_OPEN - - state = self.hass.states.set('cover.test', STATE_OPEN) - self.hass.block_till_done() - entity = self.hass.states.get('cover.test') - attrs['position'] = 0.0 - self.hass.states.set( - entity.entity_id, entity.state, - attributes=attrs) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - assert state.state == STATE_CLOSED - - def test_template_tilt(self): - """Test the tilt_template attribute.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'tilt_template': - "{{ 42 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('cover.test', STATE_CLOSED) + await hass.async_block_till_done() + + entity = hass.states.get('cover.test') + attrs = dict() + attrs['position'] = 42 + hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + assert state.state == STATE_OPEN + + state = hass.states.async_set('cover.test', STATE_OPEN) + await hass.async_block_till_done() + entity = hass.states.get('cover.test') + attrs['position'] = 0.0 + hass.states.async_set( + entity.entity_id, entity.state, + attributes=attrs) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + assert state.state == STATE_CLOSED + + +async def test_template_tilt(hass, calls): + """Test the tilt_template attribute.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'tilt_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 - - def test_template_out_of_bounds(self): - """Test template out-of-bounds condition.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ -1 }}", - 'tilt_template': - "{{ 110 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + +async def test_template_out_of_bounds(hass, calls): + """Test template out-of-bounds condition.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ -1 }}", + 'tilt_template': + "{{ 110 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - assert state.attributes.get('current_position') is None - - def test_template_mutex(self): - """Test that only value or position template can be used.""" - with assert_setup_component(0, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'position_template': - "{{ 42 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'icon_template': - "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}" - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + +async def test_template_mutex(hass, calls): + """Test that only value or position template can be used.""" + with assert_setup_component(0, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'position_template': + "{{ 42 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" } } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_open_or_position(self): - """Test that at least one of open_cover or set_position is used.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_open_or_position(hass, calls): + """Test that at least one of open_cover or set_position is used.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", } } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_open_and_close(self): - """Test that if open_cover is specified, close_cover is too.""" - with assert_setup_component(0, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ 1 == 1 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_open_and_close(hass, calls): + """Test that if open_cover is specified, close_cover is too.""" + with assert_setup_component(0, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ 1 == 1 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' }, - } + }, } - }) - - self.hass.start() - self.hass.block_till_done() - - assert self.hass.states.all() == [] - - def test_template_non_numeric(self): - """Test that tilt_template values are numeric.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ on }}", - 'tilt_template': - "{% if states.cover.test_state.state %}" - "on" - "{% else %}" - "off" - "{% endif %}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + assert hass.states.async_all() == [] + + +async def test_template_non_numeric(hass, calls): + """Test that tilt_template values are numeric.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ on }}", + 'tilt_template': + "{% if states.cover.test_state.state %}" + "on" + "{% else %}" + "off" + "{% endif %}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - assert state.attributes.get('current_position') is None - - def test_open_action(self): - """Test the open_cover command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 0 }}", - 'open_cover': { - 'service': 'test.automation', - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + assert state.attributes.get('current_position') is None + + +async def test_open_action(hass, calls): + """Test the open_cover command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 0 }}", + 'open_cover': { + 'service': 'test.automation', + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_close_stop_action(self): - """Test the close-cover and stop_cover commands.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'test.automation', - }, - 'stop_cover': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_close_stop_action(hass, calls): + """Test the close-cover and stop_cover commands.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'test.automation', + }, + 'stop_cover': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 2 - - def test_set_position(self): - """Test the set_position command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'input_number', { - 'input_number': { - 'test': { - 'min': '0', - 'max': '100', - 'initial': '42', - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 2 + + +async def test_set_position(hass, calls): + """Test the set_position command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'input_number', { + 'input_number': { + 'test': { + 'min': '0', + 'max': '100', + 'initial': '42', } - }) - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ states.input_number.test.state | int }}", - 'set_cover_position': { - 'service': 'input_number.set_value', - 'entity_id': 'input_number.test', - 'data_template': { - 'value': '{{ position }}' - }, + } + }) + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ states.input_number.test.state | int }}", + 'set_cover_position': { + 'service': 'input_number.set_value', + 'entity_id': 'input_number.test', + 'data_template': { + 'value': '{{ position }}' }, - } + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.set('input_number.test', 42) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 100.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 25.0 - - def test_set_tilt_position(self): - """Test the set_tilt_position command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.async_set('input_number.test', 42) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 100.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 25.0 + + +async def test_set_tilt_position(hass, calls): + """Test the set_tilt_position command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, - blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_open_tilt_action(self): - """Test the open_cover_tilt command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_open_tilt_action(hass, calls): + """Test the open_cover_tilt command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_close_tilt_action(self): - """Test the close_cover_tilt command.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_close_tilt_action(hass, calls): + """Test the close_cover_tilt command.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - - self.hass.start() - self.hass.block_till_done() - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - - assert len(self.calls) == 1 - - def test_set_position_optimistic(self): - """Test optimistic position mode.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'set_cover_position': { - 'service': 'test.automation', - }, - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + + assert len(calls) == 1 + + +async def test_set_position_optimistic(hass, calls): + """Test optimistic position mode.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'set_cover_position': { + 'service': 'test.automation', + }, } } - }) - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') is None - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN - - def test_set_tilt_position_optimistic(self): - """Test the optimistic tilt_position mode.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'position_template': - "{{ 100 }}", - 'set_cover_position': { - 'service': 'test.automation', - }, - 'set_cover_tilt_position': { - 'service': 'test.automation', - }, - } + } + }) + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') is None + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') == 42.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_CLOSED + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.state == STATE_OPEN + + +async def test_set_tilt_position_optimistic(hass, calls): + """Test the optimistic tilt_position mode.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'position_template': + "{{ 100 }}", + 'set_cover_position': { + 'service': 'test.automation', + }, + 'set_cover_tilt_position': { + 'service': 'test.automation', + }, } } - }) - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') is None - - self.hass.services.call( - DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, - blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 - - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 0.0 - - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 100.0 - - def test_icon_template(self): - """Test icon template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'icon_template': - "{% if states.cover.test_state.state %}" - "mdi:check" - "{% endif %}" - } + } + }) + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') is None + + await hass.services.async_call( + DOMAIN, SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42}, + blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 42.0 + + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 0.0 + + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_tilt_position') == 100.0 + + +async def test_icon_template(hass, calls): + """Test icon template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'icon_template': + "{% if states.cover.test_state.state %}" + "mdi:check" + "{% endif %}" } } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('icon') == '' - - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() - - state = self.hass.states.get('cover.test_template_cover') - - assert state.attributes['icon'] == 'mdi:check' - - def test_entity_picture_template(self): - """Test icon template.""" - with assert_setup_component(1, 'cover'): - assert setup.setup_component(self.hass, 'cover', { - 'cover': { - 'platform': 'template', - 'covers': { - 'test_template_cover': { - 'value_template': - "{{ states.cover.test_state.state }}", - 'open_cover': { - 'service': 'cover.open_cover', - 'entity_id': 'cover.test_state' - }, - 'close_cover': { - 'service': 'cover.close_cover', - 'entity_id': 'cover.test_state' - }, - 'entity_picture_template': - "{% if states.cover.test_state.state %}" - "/local/cover.png" - "{% endif %}" - } + } + }) + + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('icon') == '' + + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() + + state = hass.states.get('cover.test_template_cover') + + assert state.attributes['icon'] == 'mdi:check' + + +async def test_entity_picture_template(hass, calls): + """Test icon template.""" + with assert_setup_component(1, 'cover'): + assert await setup.async_setup_component(hass, 'cover', { + 'cover': { + 'platform': 'template', + 'covers': { + 'test_template_cover': { + 'value_template': + "{{ states.cover.test_state.state }}", + 'open_cover': { + 'service': 'cover.open_cover', + 'entity_id': 'cover.test_state' + }, + 'close_cover': { + 'service': 'cover.close_cover', + 'entity_id': 'cover.test_state' + }, + 'entity_picture_template': + "{% if states.cover.test_state.state %}" + "/local/cover.png" + "{% endif %}" } } - }) + } + }) - self.hass.start() - self.hass.block_till_done() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('entity_picture') == '' + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('entity_picture') == '' - state = self.hass.states.set('cover.test_state', STATE_OPEN) - self.hass.block_till_done() + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') + state = hass.states.get('cover.test_template_cover') - assert state.attributes['entity_picture'] == '/local/cover.png' + assert state.attributes['entity_picture'] == '/local/cover.png' diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index b83756f6ebbae4..5fa8ddcfe38e79 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -1,6 +1,9 @@ """Test deCONZ component setup process.""" from unittest.mock import Mock, patch +import pytest +import voluptuous as vol + from homeassistant.setup import async_setup_component from homeassistant.components import deconz @@ -163,11 +166,13 @@ async def test_service_configure(hass): await hass.async_block_till_done() # field does not start with / - with patch('pydeconz.DeconzSession.async_put_state', - return_value=mock_coro(True)): - await hass.services.async_call('deconz', 'configure', service_data={ - 'entity': 'light.test', 'field': 'state', 'data': data}) - await hass.async_block_till_done() + with pytest.raises(vol.Invalid): + with patch('pydeconz.DeconzSession.async_put_state', + return_value=mock_coro(True)): + await hass.services.async_call( + 'deconz', 'configure', service_data={ + 'entity': 'light.test', 'field': 'state', 'data': data}) + await hass.async_block_till_done() async def test_service_refresh_devices(hass): diff --git a/tests/components/device_tracker/common.py b/tests/components/device_tracker/common.py new file mode 100644 index 00000000000000..b76eb9a8332674 --- /dev/null +++ b/tests/components/device_tracker/common.py @@ -0,0 +1,31 @@ +"""Collection of helper methods. + +All containing methods are legacy helpers that should not be used by new +components. Instead call the service directly. +""" +from homeassistant.components.device_tracker import ( + DOMAIN, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_GPS, ATTR_GPS_ACCURACY, + ATTR_LOCATION_NAME, ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, SERVICE_SEE) +from homeassistant.core import callback +from homeassistant.helpers.typing import GPSType, HomeAssistantType +from homeassistant.loader import bind_hass + + +@callback +@bind_hass +def async_see(hass: HomeAssistantType, mac: str = None, dev_id: str = None, + host_name: str = None, location_name: str = None, + gps: GPSType = None, gps_accuracy=None, + battery: int = None, attributes: dict = None): + """Call service to notify you see device.""" + data = {key: value for key, value in + ((ATTR_MAC, mac), + (ATTR_DEV_ID, dev_id), + (ATTR_HOST_NAME, host_name), + (ATTR_LOCATION_NAME, location_name), + (ATTR_GPS, gps), + (ATTR_GPS_ACCURACY, gps_accuracy), + (ATTR_BATTERY, battery)) if value is not None} + if attributes: + data[ATTR_ATTRIBUTES] = attributes + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SEE, data)) diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 93de359610f4da..6f0d881d25706e 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -3,504 +3,514 @@ import asyncio import json import logging -import unittest -from unittest.mock import call, patch +from unittest.mock import call from datetime import datetime, timedelta import os +from asynctest import patch +import pytest from homeassistant.components import zone from homeassistant.core import callback, State -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.helpers import discovery from homeassistant.loader import get_component -from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM, ATTR_ICON) import homeassistant.components.device_tracker as device_tracker +from tests.components.device_tracker import common from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.json import JSONEncoder from tests.common import ( - get_test_home_assistant, fire_time_changed, - patch_yaml_files, assert_setup_component, mock_restore_cache) -import pytest + async_fire_time_changed, patch_yaml_files, assert_setup_component, + mock_restore_cache) TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}} _LOGGER = logging.getLogger(__name__) -class TestComponentsDeviceTracker(unittest.TestCase): - """Test the Device tracker.""" - - hass = None # HomeAssistant - yaml_devices = None # type: str - - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.yaml_devices = self.hass.config.path(device_tracker.YAML_DEVICES) - - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - if os.path.isfile(self.yaml_devices): - os.remove(self.yaml_devices) - - self.hass.stop() - - def test_is_on(self): - """Test is_on method.""" - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - - self.hass.states.set(entity_id, STATE_HOME) - - assert device_tracker.is_on(self.hass, entity_id) - - self.hass.states.set(entity_id, STATE_NOT_HOME) - - assert not device_tracker.is_on(self.hass, entity_id) - - # pylint: disable=no-self-use - def test_reading_broken_yaml_config(self): - """Test when known devices contains invalid data.""" - files = {'empty.yaml': '', - 'nodict.yaml': '100', - 'badkey.yaml': '@:\n name: Device', - 'noname.yaml': 'my_device:\n', - 'allok.yaml': 'My Device:\n name: Device', - 'oneok.yaml': ('My Device!:\n name: Device\n' - 'bad_device:\n nme: Device')} - args = {'hass': self.hass, 'consider_home': timedelta(seconds=60)} - with patch_yaml_files(files): - assert device_tracker.load_config('empty.yaml', **args) == [] - assert device_tracker.load_config('nodict.yaml', **args) == [] - assert device_tracker.load_config('noname.yaml', **args) == [] - assert device_tracker.load_config('badkey.yaml', **args) == [] - - res = device_tracker.load_config('allok.yaml', **args) - assert len(res) == 1 - assert res[0].name == 'Device' - assert res[0].dev_id == 'my_device' - - res = device_tracker.load_config('oneok.yaml', **args) - assert len(res) == 1 - assert res[0].name == 'Device' - assert res[0].dev_id == 'my_device' - - def test_reading_yaml_config(self): - """Test the rendering of the YAML configuration.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', - hide_if_away=True, icon='mdi:kettle') - device_tracker.update_config(self.yaml_devices, dev_id, device) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - config = device_tracker.load_config(self.yaml_devices, self.hass, - device.consider_home)[0] - assert device.dev_id == config.dev_id - assert device.track == config.track - assert device.mac == config.mac - assert device.config_picture == config.config_picture - assert device.away_hide == config.away_hide - assert device.consider_home == config.consider_home - assert device.icon == config.icon - - # pylint: disable=invalid-name - @patch('homeassistant.components.device_tracker._LOGGER.warning') - def test_track_with_duplicate_mac_dev_id(self, mock_warning): - """Test adding duplicate MACs or device IDs to DeviceTracker.""" - devices = [ - device_tracker.Device(self.hass, True, True, 'my_device', 'AB:01', - 'My device', None, None, False), - device_tracker.Device(self.hass, True, True, 'your_device', - 'AB:01', 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, {}, devices) - _LOGGER.debug(mock_warning.call_args_list) - assert mock_warning.call_count == 1, \ - "The only warning call should be duplicates (check DEBUG)" - args, _ = mock_warning.call_args - assert 'Duplicate device MAC' in args[0], \ - 'Duplicate MAC warning expected' - - mock_warning.reset_mock() - devices = [ - device_tracker.Device(self.hass, True, True, 'my_device', - 'AB:01', 'My device', None, None, False), - device_tracker.Device(self.hass, True, True, 'my_device', - None, 'Your device', None, None, False)] - device_tracker.DeviceTracker(self.hass, False, True, {}, devices) - - _LOGGER.debug(mock_warning.call_args_list) - assert mock_warning.call_count == 1, \ - "The only warning call should be duplicates (check DEBUG)" - args, _ = mock_warning.call_args - assert 'Duplicate device IDs' in args[0], \ - 'Duplicate device IDs warning expected' - - def test_setup_without_yaml_file(self): - """Test with no YAML file.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - def test_gravatar(self): - """Test the Gravatar generation.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') - gravatar_url = ("https://www.gravatar.com/avatar/" - "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") - assert device.config_picture == gravatar_url - - def test_gravatar_and_picture(self): - """Test that Gravatar overrides picture.""" - dev_id = 'test' - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, - 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', - gravatar='test@example.com') - gravatar_url = ("https://www.gravatar.com/avatar/" - "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") - assert device.config_picture == gravatar_url - - @patch( - 'homeassistant.components.device_tracker.DeviceTracker.see') - @patch( - 'homeassistant.components.device_tracker.demo.setup_scanner', - autospec=True) - def test_discover_platform(self, mock_demo_setup_scanner, mock_see): - """Test discovery of device_tracker demo platform.""" - assert device_tracker.DOMAIN not in self.hass.config.components - discovery.load_platform( - self.hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, - {'demo': {}}) - self.hass.block_till_done() - assert device_tracker.DOMAIN in self.hass.config.components - assert mock_demo_setup_scanner.called - assert mock_demo_setup_scanner.call_args[0] == ( - self.hass, {}, mock_see, {'test_key': 'test_val'}) - - def test_update_stale(self): - """Test stalled update.""" - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') - - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=register_time): - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'test', - device_tracker.CONF_CONSIDER_HOME: 59, - }}) - self.hass.block_till_done() - - assert STATE_HOME == \ - self.hass.states.get('device_tracker.dev1').state - - scanner.leave_home('DEV1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=scan_time): - fire_time_changed(self.hass, scan_time) - self.hass.block_till_done() - - assert STATE_NOT_HOME == \ - self.hass.states.get('device_tracker.dev1').state - - def test_entity_attributes(self): - """Test the entity attributes.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - friendly_name = 'Paulus' - picture = 'http://placehold.it/200x200' - icon = 'mdi:kettle' - - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - friendly_name, picture, hide_if_away=True, icon=icon) - device_tracker.update_config(self.yaml_devices, dev_id, device) +@pytest.fixture +def yaml_devices(hass): + """Get a path for storing yaml devices.""" + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) + yield yaml_devices + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - attrs = self.hass.states.get(entity_id).attributes +async def test_is_on(hass): + """Test is_on method.""" + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) - assert icon == attrs.get(ATTR_ICON) - assert picture == attrs.get(ATTR_ENTITY_PICTURE) + hass.states.async_set(entity_id, STATE_HOME) - def test_device_hidden(self): - """Test hidden devices.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - hide_if_away=True) - device_tracker.update_config(self.yaml_devices, dev_id, device) + assert device_tracker.is_on(hass, entity_id) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() + hass.states.async_set(entity_id, STATE_NOT_HOME) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + assert not device_tracker.is_on(hass, entity_id) - assert self.hass.states.get(entity_id) \ - .attributes.get(ATTR_HIDDEN) - def test_group_all_devices(self): - """Test grouping of devices.""" - dev_id = 'test_entity' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - device = device_tracker.Device( - self.hass, timedelta(seconds=180), True, dev_id, None, - hide_if_away=True) - device_tracker.update_config(self.yaml_devices, dev_id, device) +async def test_reading_broken_yaml_config(hass): + """Test when known devices contains invalid data.""" + files = {'empty.yaml': '', + 'nodict.yaml': '100', + 'badkey.yaml': '@:\n name: Device', + 'noname.yaml': 'my_device:\n', + 'allok.yaml': 'My Device:\n name: Device', + 'oneok.yaml': ('My Device!:\n name: Device\n' + 'bad_device:\n nme: Device')} + args = {'hass': hass, 'consider_home': timedelta(seconds=60)} + with patch_yaml_files(files): + assert await device_tracker.async_load_config( + 'empty.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'nodict.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'noname.yaml', **args) == [] + assert await device_tracker.async_load_config( + 'badkey.yaml', **args) == [] + + res = await device_tracker.async_load_config('allok.yaml', **args) + assert len(res) == 1 + assert res[0].name == 'Device' + assert res[0].dev_id == 'my_device' + + res = await device_tracker.async_load_config('oneok.yaml', **args) + assert len(res) == 1 + assert res[0].name == 'Device' + assert res[0].dev_id == 'my_device' + + +async def test_reading_yaml_config(hass, yaml_devices): + """Test the rendering of the YAML configuration.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + hide_if_away=True, icon='mdi:kettle') + device_tracker.update_config(yaml_devices, dev_id, device) + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + config = (await device_tracker.async_load_config(yaml_devices, hass, + device.consider_home))[0] + assert device.dev_id == config.dev_id + assert device.track == config.track + assert device.mac == config.mac + assert device.config_picture == config.config_picture + assert device.away_hide == config.away_hide + assert device.consider_home == config.consider_home + assert device.icon == config.icon + + +# pylint: disable=invalid-name +@patch('homeassistant.components.device_tracker._LOGGER.warning') +async def test_track_with_duplicate_mac_dev_id(mock_warning, hass): + """Test adding duplicate MACs or device IDs to DeviceTracker.""" + devices = [ + device_tracker.Device(hass, True, True, 'my_device', 'AB:01', + 'My device', None, None, False), + device_tracker.Device(hass, True, True, 'your_device', + 'AB:01', 'Your device', None, None, False)] + device_tracker.DeviceTracker(hass, False, True, {}, devices) + _LOGGER.debug(mock_warning.call_args_list) + assert mock_warning.call_count == 1, \ + "The only warning call should be duplicates (check DEBUG)" + args, _ = mock_warning.call_args + assert 'Duplicate device MAC' in args[0], \ + 'Duplicate MAC warning expected' + + mock_warning.reset_mock() + devices = [ + device_tracker.Device(hass, True, True, 'my_device', + 'AB:01', 'My device', None, None, False), + device_tracker.Device(hass, True, True, 'my_device', + None, 'Your device', None, None, False)] + device_tracker.DeviceTracker(hass, False, True, {}, devices) + + _LOGGER.debug(mock_warning.call_args_list) + assert mock_warning.call_count == 1, \ + "The only warning call should be duplicates (check DEBUG)" + args, _ = mock_warning.call_args + assert 'Duplicate device IDs' in args[0], \ + 'Duplicate device IDs warning expected' + + +async def test_setup_without_yaml_file(hass): + """Test with no YAML file.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + +async def test_gravatar(hass): + """Test the Gravatar generation.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + assert device.config_picture == gravatar_url + + +async def test_gravatar_and_picture(hass): + """Test that Gravatar overrides picture.""" + dev_id = 'test' + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, + 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture', + gravatar='test@example.com') + gravatar_url = ("https://www.gravatar.com/avatar/" + "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar") + assert device.config_picture == gravatar_url + + +@patch( + 'homeassistant.components.device_tracker.DeviceTracker.see') +@patch( + 'homeassistant.components.device_tracker.demo.setup_scanner', + autospec=True) +async def test_discover_platform(mock_demo_setup_scanner, mock_see, hass): + """Test discovery of device_tracker demo platform.""" + assert device_tracker.DOMAIN not in hass.config.components + await discovery.async_load_platform( + hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'}, + {'demo': {}}) + await hass.async_block_till_done() + assert device_tracker.DOMAIN in hass.config.components + assert mock_demo_setup_scanner.called + assert mock_demo_setup_scanner.call_args[0] == ( + hass, {}, mock_see, {'test_key': 'test_val'}) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() +async def test_update_stale(hass): + """Test stalled update.""" + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('DEV1') + + register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=register_time): with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - self.hass.block_till_done() - - state = self.hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES) - assert state is not None - assert STATE_NOT_HOME == state.state - assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID) - - @patch('homeassistant.components.device_tracker.DeviceTracker.async_see') - def test_see_service(self, mock_see): - """Test the see service with a unicode dev_id and NO MAC.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - params = { - 'dev_id': 'some_device', - 'host_name': 'example.com', - 'location_name': 'Work', - 'gps': [.3, .8], - 'attributes': { - 'test': 'test' - } - } - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - assert mock_see.call_count == 1 - assert mock_see.call_count == 1 - assert mock_see.call_args == call(**params) - - mock_see.reset_mock() - params['dev_id'] += chr(233) # e' acute accent from icloud - - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - assert mock_see.call_count == 1 - assert mock_see.call_count == 1 - assert mock_see.call_args == call(**params) - - def test_new_device_event_fired(self): - """Test that the device tracker will fire an event.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - test_events = [] + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'test', + device_tracker.CONF_CONSIDER_HOME: 59, + }}) + await hass.async_block_till_done() + + assert STATE_HOME == \ + hass.states.get('device_tracker.dev1').state - @callback - def listener(event): - """Record that our event got called.""" - test_events.append(event) + scanner.leave_home('DEV1') - self.hass.bus.listen("device_tracker_new_device", listener) + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=scan_time): + async_fire_time_changed(hass, scan_time) + await hass.async_block_till_done() - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_1', host_name='hello') + assert STATE_NOT_HOME == \ + hass.states.get('device_tracker.dev1').state - self.hass.block_till_done() - assert len(test_events) == 1 +async def test_entity_attributes(hass, yaml_devices): + """Test the entity attributes.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + friendly_name = 'Paulus' + picture = 'http://placehold.it/200x200' + icon = 'mdi:kettle' - # Assert we can serialize the event - json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + friendly_name, picture, hide_if_away=True, icon=icon) + device_tracker.update_config(yaml_devices, dev_id, device) - assert test_events[0].data == { - 'entity_id': 'device_tracker.hello', - 'host_name': 'hello', - 'mac': 'MAC_1', + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + attrs = hass.states.get(entity_id).attributes + + assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) + assert icon == attrs.get(ATTR_ICON) + assert picture == attrs.get(ATTR_ENTITY_PICTURE) + + +async def test_device_hidden(hass, yaml_devices): + """Test hidden devices.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + hide_if_away=True) + device_tracker.update_config(yaml_devices, dev_id, device) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN) + + +async def test_group_all_devices(hass, yaml_devices): + """Test grouping of devices.""" + dev_id = 'test_entity' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + device = device_tracker.Device( + hass, timedelta(seconds=180), True, dev_id, None, + hide_if_away=True) + device_tracker.update_config(yaml_devices, dev_id, device) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + await hass.async_block_till_done() + + state = hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES) + assert state is not None + assert STATE_NOT_HOME == state.state + assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID) + + +@patch('homeassistant.components.device_tracker.DeviceTracker.async_see') +async def test_see_service(mock_see, hass): + """Test the see service with a unicode dev_id and NO MAC.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + params = { + 'dev_id': 'some_device', + 'host_name': 'example.com', + 'location_name': 'Work', + 'gps': [.3, .8], + 'attributes': { + 'test': 'test' } + } + common.async_see(hass, **params) + await hass.async_block_till_done() + assert mock_see.call_count == 1 + assert mock_see.call_count == 1 + assert mock_see.call_args == call(**params) - # pylint: disable=invalid-name - def test_not_write_duplicate_yaml_keys(self): - """Test that the device tracker will not generate invalid YAML.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + mock_see.reset_mock() + params['dev_id'] += chr(233) # e' acute accent from icloud - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_2', host_name='hello') + common.async_see(hass, **params) + await hass.async_block_till_done() + assert mock_see.call_count == 1 + assert mock_see.call_count == 1 + assert mock_see.call_args == call(**params) - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 2 +async def test_new_device_event_fired(hass): + """Test that the device tracker will fire an event.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + test_events = [] - # pylint: disable=invalid-name - def test_not_allow_invalid_dev_id(self): - """Test that the device tracker will not allow invalid dev ids.""" - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - device_tracker.see(self.hass, dev_id='hello-world') - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 0 - - def test_see_state(self): - """Test device tracker see records state correctly.""" - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - params = { - 'mac': 'AA:BB:CC:DD:EE:FF', - 'dev_id': 'some_device', - 'host_name': 'example.com', - 'location_name': 'Work', - 'gps': [.3, .8], - 'gps_accuracy': 1, - 'battery': 100, - 'attributes': { - 'test': 'test', - 'number': 1, - }, + @callback + def listener(event): + """Record that our event got called.""" + test_events.append(event) + + hass.bus.async_listen("device_tracker_new_device", listener) + + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_1', host_name='hello') + + await hass.async_block_till_done() + + assert len(test_events) == 1 + + # Assert we can serialize the event + json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + + assert test_events[0].data == { + 'entity_id': 'device_tracker.hello', + 'host_name': 'hello', + 'mac': 'MAC_1', + } + + +# pylint: disable=invalid-name +async def test_not_write_duplicate_yaml_keys(hass, yaml_devices): + """Test that the device tracker will not generate invalid YAML.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_2', host_name='hello') + + await hass.async_block_till_done() + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 2 + + +# pylint: disable=invalid-name +async def test_not_allow_invalid_dev_id(hass, yaml_devices): + """Test that the device tracker will not allow invalid dev ids.""" + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + common.async_see(hass, dev_id='hello-world') + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 0 + + +async def test_see_state(hass, yaml_devices): + """Test device tracker see records state correctly.""" + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) + + params = { + 'mac': 'AA:BB:CC:DD:EE:FF', + 'dev_id': 'some_device', + 'host_name': 'example.com', + 'location_name': 'Work', + 'gps': [.3, .8], + 'gps_accuracy': 1, + 'battery': 100, + 'attributes': { + 'test': 'test', + 'number': 1, + }, + } + + common.async_see(hass, **params) + await hass.async_block_till_done() + + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert len(config) == 1 + + state = hass.states.get('device_tracker.examplecom') + attrs = state.attributes + assert state.state == 'Work' + assert state.object_id == 'examplecom' + assert state.name == 'example.com' + assert attrs['friendly_name'] == 'example.com' + assert attrs['battery'] == 100 + assert attrs['latitude'] == 0.3 + assert attrs['longitude'] == 0.8 + assert attrs['test'] == 'test' + assert attrs['gps_accuracy'] == 1 + assert attrs['source_type'] == 'gps' + assert attrs['number'] == 1 + + +async def test_see_passive_zone_state(hass): + """Test that the device tracker sets gps for passive trackers.""" + register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) + scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + + with assert_setup_component(1, zone.DOMAIN): + zone_info = { + 'name': 'Home', + 'latitude': 1, + 'longitude': 2, + 'radius': 250, + 'passive': False } - device_tracker.see(self.hass, **params) - self.hass.block_till_done() - - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 1 - - state = self.hass.states.get('device_tracker.examplecom') - attrs = state.attributes - assert state.state == 'Work' - assert state.object_id == 'examplecom' - assert state.name == 'example.com' - assert attrs['friendly_name'] == 'example.com' - assert attrs['battery'] == 100 - assert attrs['latitude'] == 0.3 - assert attrs['longitude'] == 0.8 - assert attrs['test'] == 'test' - assert attrs['gps_accuracy'] == 1 - assert attrs['source_type'] == 'gps' - assert attrs['number'] == 1 - - def test_see_passive_zone_state(self): - """Test that the device tracker sets gps for passive trackers.""" - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) - - with assert_setup_component(1, zone.DOMAIN): - zone_info = { - 'name': 'Home', - 'latitude': 1, - 'longitude': 2, - 'radius': 250, - 'passive': False - } - - setup_component(self.hass, zone.DOMAIN, { - 'zone': zone_info - }) - - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('dev1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=register_time): - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'test', - device_tracker.CONF_CONSIDER_HOME: 59, - }}) - self.hass.block_till_done() - - state = self.hass.states.get('device_tracker.dev1') - attrs = state.attributes - assert STATE_HOME == state.state - assert state.object_id == 'dev1' - assert state.name == 'dev1' - assert attrs.get('friendly_name') == 'dev1' - assert attrs.get('latitude') == 1 - assert attrs.get('longitude') == 2 - assert attrs.get('gps_accuracy') == 0 - assert attrs.get('source_type') == \ - device_tracker.SOURCE_TYPE_ROUTER - - scanner.leave_home('dev1') - - with patch('homeassistant.components.device_tracker.dt_util.utcnow', - return_value=scan_time): - fire_time_changed(self.hass, scan_time) - self.hass.block_till_done() - - state = self.hass.states.get('device_tracker.dev1') - attrs = state.attributes - assert STATE_NOT_HOME == state.state - assert state.object_id == 'dev1' - assert state.name == 'dev1' - assert attrs.get('friendly_name') == 'dev1' - assert attrs.get('latitude')is None - assert attrs.get('longitude')is None - assert attrs.get('gps_accuracy')is None - assert attrs.get('source_type') == \ - device_tracker.SOURCE_TYPE_ROUTER - - @patch('homeassistant.components.device_tracker._LOGGER.warning') - def test_see_failures(self, mock_warning): - """Test that the device tracker see failures.""" - tracker = device_tracker.DeviceTracker( - self.hass, timedelta(seconds=60), 0, {}, []) - - # MAC is not a string (but added) - tracker.see(mac=567, host_name="Number MAC") - - # No device id or MAC(not added) - with pytest.raises(HomeAssistantError): - run_coroutine_threadsafe( - tracker.async_see(), self.hass.loop).result() - assert mock_warning.call_count == 0 - - # Ignore gps on invalid GPS (both added & warnings) - tracker.see(mac='mac_1_bad_gps', gps=1) - tracker.see(mac='mac_2_bad_gps', gps=[1]) - tracker.see(mac='mac_3_bad_gps', gps='gps') - self.hass.block_till_done() - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert mock_warning.call_count == 3 - - assert len(config) == 4 + await async_setup_component(hass, zone.DOMAIN, { + 'zone': zone_info + }) + + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() + scanner.come_home('dev1') + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=register_time): + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'test', + device_tracker.CONF_CONSIDER_HOME: 59, + }}) + await hass.async_block_till_done() + + state = hass.states.get('device_tracker.dev1') + attrs = state.attributes + assert STATE_HOME == state.state + assert state.object_id == 'dev1' + assert state.name == 'dev1' + assert attrs.get('friendly_name') == 'dev1' + assert attrs.get('latitude') == 1 + assert attrs.get('longitude') == 2 + assert attrs.get('gps_accuracy') == 0 + assert attrs.get('source_type') == \ + device_tracker.SOURCE_TYPE_ROUTER + + scanner.leave_home('dev1') + + with patch('homeassistant.components.device_tracker.dt_util.utcnow', + return_value=scan_time): + async_fire_time_changed(hass, scan_time) + await hass.async_block_till_done() + + state = hass.states.get('device_tracker.dev1') + attrs = state.attributes + assert STATE_NOT_HOME == state.state + assert state.object_id == 'dev1' + assert state.name == 'dev1' + assert attrs.get('friendly_name') == 'dev1' + assert attrs.get('latitude')is None + assert attrs.get('longitude')is None + assert attrs.get('gps_accuracy')is None + assert attrs.get('source_type') == \ + device_tracker.SOURCE_TYPE_ROUTER + + +@patch('homeassistant.components.device_tracker._LOGGER.warning') +async def test_see_failures(mock_warning, hass, yaml_devices): + """Test that the device tracker see failures.""" + tracker = device_tracker.DeviceTracker( + hass, timedelta(seconds=60), 0, {}, []) + + # MAC is not a string (but added) + await tracker.async_see(mac=567, host_name="Number MAC") + + # No device id or MAC(not added) + with pytest.raises(HomeAssistantError): + await tracker.async_see() + assert mock_warning.call_count == 0 + + # Ignore gps on invalid GPS (both added & warnings) + await tracker.async_see(mac='mac_1_bad_gps', gps=1) + await tracker.async_see(mac='mac_2_bad_gps', gps=[1]) + await tracker.async_see(mac='mac_3_bad_gps', gps='gps') + await hass.async_block_till_done() + config = await device_tracker.async_load_config(yaml_devices, hass, + timedelta(seconds=0)) + assert mock_warning.call_count == 3 + + assert len(config) == 4 @asyncio.coroutine diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py index 7cfef8f52197e0..a167a1e9fd4c2c 100644 --- a/tests/components/device_tracker/test_locative.py +++ b/tests/components/device_tracker/test_locative.py @@ -19,7 +19,7 @@ def _url(data=None): @pytest.fixture -def locative_client(loop, hass, aiohttp_client): +def locative_client(loop, hass, hass_client): """Locative mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -29,7 +29,7 @@ def locative_client(loop, hass, aiohttp_client): })) with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/device_tracker/test_meraki.py b/tests/components/device_tracker/test_meraki.py index 925ba6d66db52f..582f112f69c36f 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -13,7 +13,7 @@ @pytest.fixture -def meraki_client(loop, hass, aiohttp_client): +def meraki_client(loop, hass, hass_client): """Meraki mock client.""" assert loop.run_until_complete(async_setup_component( hass, device_tracker.DOMAIN, { @@ -25,7 +25,7 @@ def meraki_client(loop, hass, aiohttp_client): } })) - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index e760db151df6f2..abfa32ca06bbde 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -1,147 +1,144 @@ """The tests for the MQTT device tracker platform.""" -import asyncio -import unittest -from unittest.mock import patch import logging import os +from asynctest import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) + async_mock_mqtt_component, async_fire_mqtt_message) _LOGGER = logging.getLogger(__name__) -class TestComponentsDeviceTrackerMQTT(unittest.TestCase): - """Test MQTT device tracker platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_ensure_device_tracker_platform_validation(self): - """Test if platform validation was done.""" - @asyncio.coroutine - def mock_setup_scanner(hass, config, see, discovery_info=None): - """Check that Qos was added by validation.""" - assert 'qos' in config - - with patch('homeassistant.components.device_tracker.mqtt.' - 'async_setup_scanner', autospec=True, - side_effect=mock_setup_scanner) as mock_sp: - - dev_id = 'paulus' - topic = '/location/paulus' - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: topic} - } - }) - assert mock_sp.call_count == 1 - - def test_new_message(self): - """Test new message.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - topic = '/location/paulus' - location = 'work' - - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: topic} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert location == self.hass.states.get(entity_id).state - - def test_single_level_wildcard_topic(self): - """Test single level wildcard topic.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/+/paulus' - topic = '/location/room/paulus' - location = 'work' +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert location == self.hass.states.get(entity_id).state - def test_multi_level_wildcard_topic(self): - """Test multi level wildcard topic.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/#' - topic = '/location/room/paulus' - location = 'work' +async def test_ensure_device_tracker_platform_validation(hass): + """Test if platform validation was done.""" + async def mock_setup_scanner(hass, config, see, discovery_info=None): + """Check that Qos was added by validation.""" + assert 'qos' in config - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert location == self.hass.states.get(entity_id).state + with patch('homeassistant.components.device_tracker.mqtt.' + 'async_setup_scanner', autospec=True, + side_effect=mock_setup_scanner) as mock_sp: - def test_single_level_wildcard_topic_not_matching(self): - """Test not matching single level wildcard topic.""" dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/+/paulus' topic = '/location/paulus' - location = 'work' - - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None - - def test_multi_level_wildcard_topic_not_matching(self): - """Test not matching multi level wildcard topic.""" - dev_id = 'paulus' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = '/location/#' - topic = '/somewhere/room/paulus' - location = 'work' - - self.hass.config.components = set(['mqtt', 'zone']) - assert setup_component(self.hass, device_tracker.DOMAIN, { + assert await async_setup_component(hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt', - 'devices': {dev_id: subscription} + 'devices': {dev_id: topic} } }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None + assert mock_sp.call_count == 1 + + +async def test_new_message(hass): + """Test new message.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + topic = '/location/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: topic} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_single_level_wildcard_topic(hass): + """Test single level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/+/paulus' + topic = '/location/room/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_multi_level_wildcard_topic(hass): + """Test multi level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/#' + topic = '/location/room/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert location == hass.states.get(entity_id).state + + +async def test_single_level_wildcard_topic_not_matching(hass): + """Test not matching single level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/+/paulus' + topic = '/location/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None + + +async def test_multi_level_wildcard_topic_not_matching(hass): + """Test not matching multi level wildcard topic.""" + dev_id = 'paulus' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = '/location/#' + topic = '/somewhere/room/paulus' + location = 'work' + + hass.config.components = set(['mqtt', 'zone']) + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 44d687a4d4536e..252d40338fca96 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -1,17 +1,15 @@ """The tests for the JSON MQTT device tracker platform.""" -import asyncio import json -import unittest -from unittest.mock import patch +from asynctest import patch import logging import os +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.const import CONF_PLATFORM -from tests.common import ( - get_test_home_assistant, mock_mqtt_component, fire_mqtt_message) +from tests.common import async_mock_mqtt_component, async_fire_mqtt_message _LOGGER = logging.getLogger(__name__) @@ -25,172 +23,172 @@ 'longitude': 2.0} -class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): - """Test JSON MQTT device tracker platform.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - mock_mqtt_component(self.hass) - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - def test_ensure_device_tracker_platform_validation(self): - """Test if platform validation was done.""" - @asyncio.coroutine - def mock_setup_scanner(hass, config, see, discovery_info=None): - """Check that Qos was added by validation.""" - assert 'qos' in config - - with patch('homeassistant.components.device_tracker.mqtt_json.' - 'async_setup_scanner', autospec=True, - side_effect=mock_setup_scanner) as mock_sp: - - dev_id = 'paulus' - topic = 'location/paulus' - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) - assert mock_sp.call_count == 1 - - def test_json_message(self): - """Test json location message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE) - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - state = self.hass.states.get('device_tracker.zanzito') - assert state.attributes.get('latitude') == 2.0 - assert state.attributes.get('longitude') == 1.0 - - def test_non_json_message(self): - """Test receiving a non JSON message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = 'home' - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + hass.loop.run_until_complete(async_mock_mqtt_component(hass)) + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert "ERROR:homeassistant.components.device_tracker.mqtt_json:" \ - "Error parsing JSON payload: home" in \ - test_handle.output[0] - def test_incomplete_message(self): - """Test receiving an incomplete message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) +async def test_ensure_device_tracker_platform_validation(hass): + """Test if platform validation was done.""" + async def mock_setup_scanner(hass, config, see, discovery_info=None): + """Check that Qos was added by validation.""" + assert 'qos' in config - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) + with patch('homeassistant.components.device_tracker.mqtt_json.' + 'async_setup_scanner', autospec=True, + side_effect=mock_setup_scanner) as mock_sp: - with self.assertLogs(level='ERROR') as test_handle: - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert "ERROR:homeassistant.components.device_tracker.mqtt_json:" \ - "Skipping update for following data because of missing " \ - "or malformatted data: {\"longitude\": 2.0}" in \ - test_handle.output[0] - - def test_single_level_wildcard_topic(self): - """Test single level wildcard topic.""" - dev_id = 'zanzito' - subscription = 'location/+/zanzito' - topic = 'location/room/zanzito' - location = json.dumps(LOCATION_MESSAGE) - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - state = self.hass.states.get('device_tracker.zanzito') - assert state.attributes.get('latitude') == 2.0 - assert state.attributes.get('longitude') == 1.0 - - def test_multi_level_wildcard_topic(self): - """Test multi level wildcard topic.""" - dev_id = 'zanzito' - subscription = 'location/#' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE) - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - state = self.hass.states.get('device_tracker.zanzito') - assert state.attributes.get('latitude') == 2.0 - assert state.attributes.get('longitude') == 1.0 - - def test_single_level_wildcard_topic_not_matching(self): - """Test not matching single level wildcard topic.""" - dev_id = 'zanzito' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = 'location/+/zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE) - - assert setup_component(self.hass, device_tracker.DOMAIN, { + dev_id = 'paulus' + topic = 'location/paulus' + assert await async_setup_component(hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: { CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: subscription} - } - }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None - - def test_multi_level_wildcard_topic_not_matching(self): - """Test not matching multi level wildcard topic.""" - dev_id = 'zanzito' - entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) - subscription = 'location/#' - topic = 'somewhere/zanzito' - location = json.dumps(LOCATION_MESSAGE) - - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: subscription} + 'devices': {dev_id: topic} } }) - fire_mqtt_message(self.hass, topic, location) - self.hass.block_till_done() - assert self.hass.states.get(entity_id) is None + assert mock_sp.call_count == 1 + + +async def test_json_message(hass): + """Test json location message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_non_json_message(hass, caplog): + """Test receiving a non JSON message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = 'home' + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + + caplog.set_level(logging.ERROR) + caplog.clear() + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert "Error parsing JSON payload: home" in \ + caplog.text + + +async def test_incomplete_message(hass, caplog): + """Test receiving an incomplete message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) + + caplog.set_level(logging.ERROR) + caplog.clear() + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert "Skipping update for following data because of missing " \ + "or malformatted data: {\"longitude\": 2.0}" in \ + caplog.text + + +async def test_single_level_wildcard_topic(hass): + """Test single level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/+/zanzito' + topic = 'location/room/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_multi_level_wildcard_topic(hass): + """Test multi level wildcard topic.""" + dev_id = 'zanzito' + subscription = 'location/#' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + state = hass.states.get('device_tracker.zanzito') + assert state.attributes.get('latitude') == 2.0 + assert state.attributes.get('longitude') == 1.0 + + +async def test_single_level_wildcard_topic_not_matching(hass): + """Test not matching single level wildcard topic.""" + dev_id = 'zanzito' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = 'location/+/zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None + + +async def test_multi_level_wildcard_topic_not_matching(hass): + """Test not matching multi level wildcard topic.""" + dev_id = 'zanzito' + entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id) + subscription = 'location/#' + topic = 'somewhere/zanzito' + location = json.dumps(LOCATION_MESSAGE) + + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: subscription} + } + }) + async_fire_mqtt_message(hass, topic, location) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is None diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py index b50d1c6751131e..8f226f449b0569 100644 --- a/tests/components/device_tracker/test_tplink.py +++ b/tests/components/device_tracker/test_tplink.py @@ -1,7 +1,7 @@ """The tests for the tplink device tracker platform.""" import os -import unittest +import pytest from homeassistant.components import device_tracker from homeassistant.components.device_tracker.tplink import Tplink4DeviceScanner @@ -9,27 +9,19 @@ CONF_HOST) import requests_mock -from tests.common import get_test_home_assistant +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) -class TestTplink4DeviceScanner(unittest.TestCase): - """Tests for the Tplink4DeviceScanner class.""" - 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 everything that was started.""" - self.hass.stop() - try: - os.remove(self.hass.config.path(device_tracker.YAML_DEVICES)) - except FileNotFoundError: - pass - - @requests_mock.mock() - def test_get_mac_addresses_from_both_bands(self, m): - """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages.""" +async def test_get_mac_addresses_from_both_bands(hass): + """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages.""" + with requests_mock.Mocker() as m: conf_dict = { CONF_PLATFORM: 'tplink', CONF_HOST: 'fake-host', diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 6e2830eee52ea1..1b1dc1a7cb582d 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -1,14 +1,12 @@ """The tests for the Unifi direct device tracker platform.""" import os from datetime import timedelta -import unittest -from unittest import mock -from unittest.mock import patch +from asynctest import mock, patch import pytest import voluptuous as vol -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component from homeassistant.components import device_tracker from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE, @@ -19,133 +17,129 @@ CONF_HOST) from tests.common import ( - get_test_home_assistant, assert_setup_component, - mock_component, load_fixture) - - -class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): - """Tests for the Unifi direct device tracker platform.""" - - hass = None - scanner_path = 'homeassistant.components.device_tracker.' + \ - 'unifi_direct.UnifiDeviceScanner' - - 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(scanner_path, - return_value=mock.MagicMock()) - def test_get_scanner(self, unifi_mock): - """Test creating an Unifi direct scanner with a password.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', + assert_setup_component, mock_component, load_fixture) + +scanner_path = 'homeassistant.components.device_tracker.' + \ + 'unifi_direct.UnifiDeviceScanner' + + +@pytest.fixture(autouse=True) +def setup_comp(hass): + """Initialize components.""" + mock_component(hass, 'zone') + yaml_devices = hass.config.path(device_tracker.YAML_DEVICES) + yield + if os.path.isfile(yaml_devices): + os.remove(yaml_devices) + + +@patch(scanner_path, return_value=mock.MagicMock()) +async def test_get_scanner(unifi_mock, hass): + """Test creating an Unifi direct scanner with a password.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + 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_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: { - CONF_TRACK_NEW: True, - CONF_AWAY_HIDE: False - } + CONF_AWAY_HIDE: False } } - - with assert_setup_component(1, DOMAIN): - assert setup_component(self.hass, DOMAIN, conf_dict) - - conf_dict[DOMAIN][CONF_PORT] = 22 - assert unifi_mock.call_args == mock.call(conf_dict[DOMAIN]) - - @patch('pexpect.pxssh.pxssh') - def test_get_device_name(self, mock_ssh): - """Testing MAC matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + + with assert_setup_component(1, DOMAIN): + assert await async_setup_component(hass, DOMAIN, conf_dict) + + conf_dict[DOMAIN][CONF_PORT] = 22 + assert unifi_mock.call_args == mock.call(conf_dict[DOMAIN]) + + +@patch('pexpect.pxssh.pxssh') +async def test_get_device_name(mock_ssh, hass): + """Testing MAC matching.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } - mock_ssh.return_value.before = load_fixture('unifi_direct.txt') - scanner = get_scanner(self.hass, conf_dict) - devices = scanner.scan_devices() - assert 23 == len(devices) - assert "iPhone" == \ - scanner.get_device_name("98:00:c6:56:34:12") - assert "iPhone" == \ - scanner.get_device_name("98:00:C6:56:34:12") - - @patch('pexpect.pxssh.pxssh.logout') - @patch('pexpect.pxssh.pxssh.login') - def test_failed_to_log_in(self, mock_login, mock_logout): - """Testing exception at login results in False.""" - from pexpect import exceptions - - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + mock_ssh.return_value.before = load_fixture('unifi_direct.txt') + scanner = get_scanner(hass, conf_dict) + devices = scanner.scan_devices() + assert 23 == len(devices) + assert "iPhone" == \ + scanner.get_device_name("98:00:c6:56:34:12") + assert "iPhone" == \ + scanner.get_device_name("98:00:C6:56:34:12") + + +@patch('pexpect.pxssh.pxssh.logout') +@patch('pexpect.pxssh.pxssh.login') +async def test_failed_to_log_in(mock_login, mock_logout, hass): + """Testing exception at login results in False.""" + from pexpect import exceptions + + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } - - mock_login.side_effect = exceptions.EOF("Test") - scanner = get_scanner(self.hass, conf_dict) - assert not scanner - - @patch('pexpect.pxssh.pxssh.logout') - @patch('pexpect.pxssh.pxssh.login', autospec=True) - @patch('pexpect.pxssh.pxssh.prompt') - @patch('pexpect.pxssh.pxssh.sendline') - def test_to_get_update(self, mock_sendline, mock_prompt, mock_login, - mock_logout): - """Testing exception in get_update matching.""" - conf_dict = { - DOMAIN: { - CONF_PLATFORM: 'unifi_direct', - CONF_HOST: 'fake_host', - CONF_USERNAME: 'fake_user', - CONF_PASSWORD: 'fake_pass', - CONF_PORT: 22, - CONF_TRACK_NEW: True, - CONF_CONSIDER_HOME: timedelta(seconds=180) - } + } + + mock_login.side_effect = exceptions.EOF("Test") + scanner = get_scanner(hass, conf_dict) + assert not scanner + + +@patch('pexpect.pxssh.pxssh.logout') +@patch('pexpect.pxssh.pxssh.login', autospec=True) +@patch('pexpect.pxssh.pxssh.prompt') +@patch('pexpect.pxssh.pxssh.sendline') +async def test_to_get_update(mock_sendline, mock_prompt, mock_login, + mock_logout, hass): + """Testing exception in get_update matching.""" + conf_dict = { + DOMAIN: { + CONF_PLATFORM: 'unifi_direct', + CONF_HOST: 'fake_host', + CONF_USERNAME: 'fake_user', + CONF_PASSWORD: 'fake_pass', + CONF_PORT: 22, + CONF_TRACK_NEW: True, + CONF_CONSIDER_HOME: timedelta(seconds=180) } + } + + scanner = get_scanner(hass, conf_dict) + # mock_sendline.side_effect = AssertionError("Test") + mock_prompt.side_effect = AssertionError("Test") + devices = scanner._get_update() # pylint: disable=protected-access + assert devices is None + - scanner = get_scanner(self.hass, conf_dict) - # mock_sendline.side_effect = AssertionError("Test") - mock_prompt.side_effect = AssertionError("Test") - devices = scanner._get_update() # pylint: disable=protected-access - assert devices is None +def test_good_response_parses(hass): + """Test that the response form the AP parses to JSON correctly.""" + response = _response_to_json(load_fixture('unifi_direct.txt')) + assert response != {} - def test_good_response_parses(self): - """Test that the response form the AP parses to JSON correctly.""" - response = _response_to_json(load_fixture('unifi_direct.txt')) - assert response != {} - def test_bad_response_returns_none(self): - """Test that a bad response form the AP parses to JSON correctly.""" - assert _response_to_json("{(}") == {} +def test_bad_response_returns_none(hass): + """Test that a bad response form the AP parses to JSON correctly.""" + assert _response_to_json("{(}") == {} def test_config_error(): diff --git a/tests/components/device_tracker/test_xiaomi.py b/tests/components/device_tracker/test_xiaomi.py index 9c7c13ee741bae..7b141159256cbe 100644 --- a/tests/components/device_tracker/test_xiaomi.py +++ b/tests/components/device_tracker/test_xiaomi.py @@ -1,8 +1,6 @@ """The tests for the Xiaomi router device tracker platform.""" import logging -import unittest -from unittest import mock -from unittest.mock import patch +from asynctest import mock, patch import requests @@ -10,7 +8,6 @@ from homeassistant.components.device_tracker.xiaomi import get_scanner from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM) -from tests.common import get_test_home_assistant _LOGGER = logging.getLogger(__name__) @@ -152,113 +149,106 @@ def raise_for_status(self): _LOGGER.debug('UNKNOWN ROUTE') -class TestXiaomiDeviceScanner(unittest.TestCase): - """Xiaomi device scanner test class.""" - - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', - return_value=mock.MagicMock()) - def test_config(self, xiaomi_mock): - """Testing minimal configuration.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_PASSWORD: 'passwordTest' - }) - } - xiaomi.get_scanner(self.hass, config) - assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) - call_arg = xiaomi_mock.call_args[0][0] - assert call_arg['username'] == 'admin' - assert call_arg['password'] == 'passwordTest' - assert call_arg['host'] == '192.168.0.1' - assert call_arg['platform'] == 'device_tracker' - - @mock.patch( - 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', - return_value=mock.MagicMock()) - def test_config_full(self, xiaomi_mock): - """Testing full configuration.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: 'alternativeAdminName', - CONF_PASSWORD: 'passwordTest' - }) - } - xiaomi.get_scanner(self.hass, config) - assert xiaomi_mock.call_count == 1 - assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) - call_arg = xiaomi_mock.call_args[0][0] - assert call_arg['username'] == 'alternativeAdminName' - assert call_arg['password'] == 'passwordTest' - assert call_arg['host'] == '192.168.0.1' - assert call_arg['platform'] == 'device_tracker' - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_invalid_credential(self, mock_get, mock_post): - """Testing invalid credential handling.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: INVALID_USERNAME, - CONF_PASSWORD: 'passwordTest' - }) - } - assert get_scanner(self.hass, config) is None - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_valid_credential(self, mock_get, mock_post): - """Testing valid refresh.""" - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: 'admin', - CONF_PASSWORD: 'passwordTest' - }) - } - scanner = get_scanner(self.hass, config) - assert scanner is not None - assert 2 == len(scanner.scan_devices()) - assert "Device1" == \ - scanner.get_device_name("23:83:BF:F6:38:A0") - assert "Device2" == \ - scanner.get_device_name("1D:98:EC:5E:D5:A6") - - @patch('requests.get', side_effect=mocked_requests) - @patch('requests.post', side_effect=mocked_requests) - def test_token_timed_out(self, mock_get, mock_post): - """Testing refresh with a timed out token. - - New token is requested and list is downloaded a second time. - """ - config = { - DOMAIN: xiaomi.PLATFORM_SCHEMA({ - CONF_PLATFORM: xiaomi.DOMAIN, - CONF_HOST: '192.168.0.1', - CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, - CONF_PASSWORD: 'passwordTest' - }) - } - scanner = get_scanner(self.hass, config) - assert scanner is not None - assert 2 == len(scanner.scan_devices()) - assert "Device1" == \ - scanner.get_device_name("23:83:BF:F6:38:A0") - assert "Device2" == \ - scanner.get_device_name("1D:98:EC:5E:D5:A6") +@patch( + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', + return_value=mock.MagicMock()) +async def test_config(xiaomi_mock, hass): + """Testing minimal configuration.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_PASSWORD: 'passwordTest' + }) + } + xiaomi.get_scanner(hass, config) + assert xiaomi_mock.call_count == 1 + assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) + call_arg = xiaomi_mock.call_args[0][0] + assert call_arg['username'] == 'admin' + assert call_arg['password'] == 'passwordTest' + assert call_arg['host'] == '192.168.0.1' + assert call_arg['platform'] == 'device_tracker' + + +@patch( + 'homeassistant.components.device_tracker.xiaomi.XiaomiDeviceScanner', + return_value=mock.MagicMock()) +async def test_config_full(xiaomi_mock, hass): + """Testing full configuration.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: 'alternativeAdminName', + CONF_PASSWORD: 'passwordTest' + }) + } + xiaomi.get_scanner(hass, config) + assert xiaomi_mock.call_count == 1 + assert xiaomi_mock.call_args == mock.call(config[DOMAIN]) + call_arg = xiaomi_mock.call_args[0][0] + assert call_arg['username'] == 'alternativeAdminName' + assert call_arg['password'] == 'passwordTest' + assert call_arg['host'] == '192.168.0.1' + assert call_arg['platform'] == 'device_tracker' + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_invalid_credential(mock_get, mock_post, hass): + """Testing invalid credential handling.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: INVALID_USERNAME, + CONF_PASSWORD: 'passwordTest' + }) + } + assert get_scanner(hass, config) is None + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_valid_credential(mock_get, mock_post, hass): + """Testing valid refresh.""" + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: 'admin', + CONF_PASSWORD: 'passwordTest' + }) + } + scanner = get_scanner(hass, config) + assert scanner is not None + assert 2 == len(scanner.scan_devices()) + assert "Device1" == \ + scanner.get_device_name("23:83:BF:F6:38:A0") + assert "Device2" == \ + scanner.get_device_name("1D:98:EC:5E:D5:A6") + + +@patch('requests.get', side_effect=mocked_requests) +@patch('requests.post', side_effect=mocked_requests) +async def test_token_timed_out(mock_get, mock_post, hass): + """Testing refresh with a timed out token. + + New token is requested and list is downloaded a second time. + """ + config = { + DOMAIN: xiaomi.PLATFORM_SCHEMA({ + CONF_PLATFORM: xiaomi.DOMAIN, + CONF_HOST: '192.168.0.1', + CONF_USERNAME: TOKEN_TIMEOUT_USERNAME, + CONF_PASSWORD: 'passwordTest' + }) + } + scanner = get_scanner(hass, config) + assert scanner is not None + assert 2 == len(scanner.scan_devices()) + assert "Device1" == \ + scanner.get_device_name("23:83:BF:F6:38:A0") + assert "Device2" == \ + scanner.get_device_name("1D:98:EC:5E:D5:A6") diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 9c549f00ee8015..0a82dc3513d299 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -6,10 +6,8 @@ import requests from aiohttp.hdrs import CONTENT_TYPE -from homeassistant import setup, const, core -import homeassistant.components as core_components +from homeassistant import setup, const from homeassistant.components import emulated_hue, http -from homeassistant.util.async_ import run_coroutine_threadsafe from tests.common import get_test_instance_port, get_test_home_assistant @@ -20,29 +18,6 @@ JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON} -def setup_hass_instance(emulated_hue_config): - """Set up the Home Assistant instance to test.""" - hass = get_test_home_assistant() - - # We need to do this to get access to homeassistant/turn_(on,off) - run_coroutine_threadsafe( - core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop - ).result() - - setup.setup_component( - hass, http.DOMAIN, - {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) - - setup.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config) - - return hass - - -def start_hass_instance(hass): - """Start the Home Assistant instance to test.""" - hass.start() - - class TestEmulatedHue(unittest.TestCase): """Test the emulated Hue component.""" @@ -53,11 +28,6 @@ def setUpClass(cls): """Set up the class.""" cls.hass = hass = get_test_home_assistant() - # We need to do this to get access to homeassistant/turn_(on,off) - run_coroutine_threadsafe( - core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop - ).result() - setup.setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}) diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py index a3f76058c76c1b..a3e8b0e9f32d6c 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/fan/test_mqtt.py @@ -130,6 +130,38 @@ async def test_discovery_removal_fan(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_fan(hass, mqtt_mock, caplog): + """Test removal of discovered fan.""" + entry = MockConfigEntry(domain='mqtt') + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "command_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('fan.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('fan.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('fan.milk') + assert state is None + + async def test_unique_id(hass): """Test unique_id option only creates one fan per id.""" await async_mock_mqtt_component(hass) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 2e78e0441a3ef9..9f386ceb90438f 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -59,8 +59,16 @@ def mock_http_client_with_urls(hass, aiohttp_client): return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) +@pytest.fixture +def mock_onboarded(): + """Mock that we're onboarded.""" + with patch('homeassistant.components.onboarding.async_is_onboarded', + return_value=True): + yield + + @asyncio.coroutine -def test_frontend_and_static(mock_http_client): +def test_frontend_and_static(mock_http_client, mock_onboarded): """Test if we can get the frontend.""" resp = yield from mock_http_client.get('') assert resp.status == 200 @@ -220,7 +228,7 @@ async def test_missing_themes(hass, hass_ws_client): @asyncio.coroutine -def test_extra_urls(mock_http_client_with_urls): +def test_extra_urls(mock_http_client_with_urls, mock_onboarded): """Test that extra urls are loaded.""" resp = yield from mock_http_client_with_urls.get('/states?latest') assert resp.status == 200 @@ -229,7 +237,7 @@ def test_extra_urls(mock_http_client_with_urls): @asyncio.coroutine -def test_extra_urls_es5(mock_http_client_with_urls): +def test_extra_urls_es5(mock_http_client_with_urls, mock_onboarded): """Test that es5 extra urls are loaded.""" resp = yield from mock_http_client_with_urls.get('/states?es5') assert resp.status == 200 @@ -280,7 +288,7 @@ async def test_get_translations(hass, hass_ws_client): assert msg['result'] == {'resources': {'lang': 'nl'}} -async def test_auth_load(mock_http_client): +async def test_auth_load(mock_http_client, mock_onboarded): """Test auth component loaded by default.""" resp = await mock_http_client.get('/auth/providers') assert resp.status == 200 diff --git a/tests/components/geo_location/test_geo_json_events.py b/tests/components/geo_location/test_geo_json_events.py index f476598adc9c43..46d1ed630c4ca1 100644 --- a/tests/components/geo_location/test_geo_json_events.py +++ b/tests/components/geo_location/test_geo_json_events.py @@ -1,19 +1,16 @@ """The tests for the geojson platform.""" -import unittest -from unittest import mock -from unittest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call -import homeassistant from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.geo_location.geo_json_events import \ - SCAN_INTERVAL, ATTR_EXTERNAL_ID + SCAN_INTERVAL, ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT -from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant, assert_setup_component, \ - fire_time_changed + ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util URL = 'http://geo.json.local/geo_json_events.json' @@ -27,200 +24,218 @@ ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'geo_json_events', + CONF_URL: URL, + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} -class TestGeoJsonPlatform(unittest.TestCase): - """Test the geojson platform.""" - - def setUp(self): - """Initialize values for this testcase class.""" - self.hass = get_test_home_assistant() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - @staticmethod - def _generate_mock_feed_entry(external_id, title, distance_to_home, - coordinates): - """Construct a mock feed entry for testing purposes.""" - feed_entry = MagicMock() - feed_entry.external_id = external_id - feed_entry.title = title - feed_entry.distance_to_home = distance_to_home - feed_entry.coordinates = coordinates - return feed_entry - - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup(self, mock_feed): - """Test the general setup of the platform.""" - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) - mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1)) - mock_entry_3 = self._generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = self._generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (-31.3, 150.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] - - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - # Collect events. - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - state = self.hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-15.5), 7) == 0 - - state = self.hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-20.5), 7) == 0 - - state = self.hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'geo_json_events'} - assert round(abs(float(state.state)-25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + - 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 0 - - @mock.patch('geojson_client.generic_feed.GenericFeed') - def test_setup_race_condition(self, mock_feed): - """Test a particular race condition experienced.""" - # 1. Feed returns 1 entry -> Feed manager creates 1 entity. - # 2. Feed returns error -> Feed manager removes 1 entity. - # However, this stayed on and kept listening for dispatcher signals. - # 3. Feed returns 1 entry -> Feed manager creates 1 entity. - # 4. Feed returns 1 entry -> Feed manager updates 1 entity. - # Internally, the previous entity is updating itself, too. - # 5. Feed returns error -> Feed manager removes 1 entity. - # There are now 2 entities trying to remove themselves from HA, but - # the second attempt fails of course. - - # Set up some mock feed entries for this test. - mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5, - (-31.0, 150.0)) + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'geo_json_events'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 2000.5, (-31.1, 150.1)) + + with patch('geojson_client.generic_feed.GenericFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert setup_component(self.hass, geo_location.DOMAIN, CONFIG) - - # This gives us the ability to assert the '_delete_callback' - # has been called while still executing it. - original_delete_callback = homeassistant.components\ - .geo_location.geo_json_events.GeoJsonLocationEvent\ - ._delete_callback - - def mock_delete_callback(entity): - original_delete_callback(entity) - - with patch('homeassistant.components.geo_location' - '.geo_json_events.GeoJsonLocationEvent' - '._delete_callback', - side_effect=mock_delete_callback, - autospec=True) as mocked_delete_callback: - - # Artificially trigger update. - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - # Collect events. - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + SCAN_INTERVAL) - self.hass.block_till_done() - - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 - - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 2 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Simulate an update - 1 entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1] - fire_time_changed(self.hass, utcnow + 3 * SCAN_INTERVAL) - self.hass.block_till_done() - - all_states = self.hass.states.all() - assert len(all_states) == 1 - - # Reset mocked method for the next test. - mocked_delete_callback.reset_mock() - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - fire_time_changed(self.hass, utcnow + 4 * SCAN_INTERVAL) - self.hass.block_till_done() - - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (15.1, 25.2), URL, filter_radius=200.0) + + +async def test_setup_race_condition(hass): + """Test a particular race condition experienced.""" + # 1. Feed returns 1 entry -> Feed manager creates 1 entity. + # 2. Feed returns error -> Feed manager removes 1 entity. + # However, this stayed on and kept listening for dispatcher signals. + # 3. Feed returns 1 entry -> Feed manager creates 1 entity. + # 4. Feed returns 1 entry -> Feed manager updates 1 entity. + # Internally, the previous entity is updating itself, too. + # 5. Feed returns error -> Feed manager removes 1 entity. + # There are now 2 entities trying to remove themselves from HA, but + # the second attempt fails of course. + + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0)) + delete_signal = SIGNAL_DELETE_ENTITY.format('1234') + update_signal = SIGNAL_UPDATE_ENTITY.format('1234') + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.generic_feed.GenericFeed') as mock_feed: + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - 1 entry + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + # Ensure that delete and update signal targets are now empty. + assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 + assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 diff --git a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py index 75397d27383fec..3254fd570ceece 100644 --- a/tests/components/geo_location/test_nsw_rural_fire_service_feed.py +++ b/tests/components/geo_location/test_nsw_rural_fire_service_feed.py @@ -1,6 +1,6 @@ """The tests for the geojson platform.""" import datetime -from asynctest.mock import patch, MagicMock +from asynctest.mock import patch, MagicMock, call from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -8,24 +8,33 @@ ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, ATTR_FIRE, ATTR_LOCATION, \ ATTR_COUNCIL_AREA, ATTR_STATUS, ATTR_TYPE, ATTR_SIZE, \ ATTR_RESPONSIBLE_AGENCY, ATTR_PUBLICATION_DATE -from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \ - CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ - ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, \ + ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, \ + CONF_LONGITUDE, CONF_RADIUS, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_fire_time_changed import homeassistant.util.dt as dt_util -URL = 'http://geo.json.local/geo_json_events.json' CONFIG = { geo_location.DOMAIN: [ { 'platform': 'nsw_rural_fire_service_feed', - CONF_URL: URL, CONF_RADIUS: 200 } ] } +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'nsw_rural_fire_service_feed', + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} + def _generate_mock_feed_entry(external_id, title, distance_to_home, coordinates, category=None, location=None, @@ -55,107 +64,130 @@ def _generate_mock_feed_entry(external_id, title, distance_to_home, async def test_setup(hass): """Test the general setup of the platform.""" # Set up some mock feed entries for this test. - with patch('geojson_client.nsw_rural_fire_service_feed.' - 'NswRuralFireServiceFeed') as mock_feed: - mock_entry_1 = _generate_mock_feed_entry( - '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', - location='Location 1', attribution='Attribution 1', - publication_date=datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - council_area='Council Area 1', status='Status 1', - entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') - mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, - (-31.1, 150.1), - fire=False) - mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, - (-31.2, 150.2)) - mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, - (-31.3, 150.3)) + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1', + location='Location 1', attribution='Attribution 1', + publication_date=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + council_area='Council Area 1', status='Status 1', + entry_type='Type 1', size='Size 1', responsible_agency='Agency 1') + mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5, + (-31.1, 150.1), + fire=False) + mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5, + (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5, + (-31.3, 150.3)) + + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, mock_entry_2, mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_PUBLICATION_DATE: + datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + ATTR_FIRE: True, + ATTR_COUNCIL_AREA: 'Council Area 1', + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_FIRE: False, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_FIRE: True, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'nsw_rural_fire_service_feed'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (-31.1, 150.1)) + + with patch('geojson_client.nsw_rural_fire_service_feed.' + 'NswRuralFireServiceFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 - utcnow = dt_util.utcnow() - # Patching 'utcnow' to gain more control over the timed update. - with patch('homeassistant.util.dt.utcnow', return_value=utcnow): - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component( - hass, geo_location.DOMAIN, CONFIG) - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - state = hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", - ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1", - ATTR_ATTRIBUTION: "Attribution 1", - ATTR_PUBLICATION_DATE: - datetime.datetime(2018, 9, 22, 8, 0, - tzinfo=datetime.timezone.utc), - ATTR_FIRE: True, - ATTR_COUNCIL_AREA: 'Council Area 1', - ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', - ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1', - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-15.5), 7) == 0 - - state = hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", - ATTR_FIRE: False, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-20.5), 7) == 0 - - state = hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", - ATTR_FIRE: True, - ATTR_UNIT_OF_MEASUREMENT: "km", - ATTR_SOURCE: 'nsw_rural_fire_service_feed'} - assert round(abs(float(state.state)-25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed.return_value.update.return_value = 'OK', [ - mock_entry_1, mock_entry_4, mock_entry_3] - async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed.return_value.update.return_value = 'OK_NO_DATA', None - # mock_restdata.return_value.data = None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = 'ERROR', None - async_fire_time_changed(hass, utcnow + - 2 * SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 + assert mock_feed.call_args == call( + (15.1, 25.2), filter_categories=[], filter_radius=200.0) diff --git a/tests/components/geo_location/test_usgs_earthquakes_feed.py b/tests/components/geo_location/test_usgs_earthquakes_feed.py new file mode 100644 index 00000000000000..f0383c221c48ed --- /dev/null +++ b/tests/components/geo_location/test_usgs_earthquakes_feed.py @@ -0,0 +1,194 @@ +"""The tests for the USGS Earthquake Hazards Program Feed platform.""" +import datetime +from unittest.mock import patch, MagicMock, call + +from homeassistant.components import geo_location +from homeassistant.components.geo_location import ATTR_SOURCE +from homeassistant.components.geo_location\ + .usgs_earthquakes_feed import \ + ATTR_ALERT, ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_PLACE, \ + ATTR_MAGNITUDE, ATTR_STATUS, ATTR_TYPE, \ + ATTR_TIME, ATTR_UPDATED, CONF_FEED_TYPE +from homeassistant.const import EVENT_HOMEASSISTANT_START, \ + CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \ + ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, async_fire_time_changed +import homeassistant.util.dt as dt_util + +CONFIG = { + geo_location.DOMAIN: [ + { + 'platform': 'usgs_earthquakes_feed', + CONF_FEED_TYPE: 'past_hour_m25_earthquakes', + CONF_RADIUS: 200 + } + ] +} + +CONFIG_WITH_CUSTOM_LOCATION = { + geo_location.DOMAIN: [ + { + 'platform': 'usgs_earthquakes_feed', + CONF_FEED_TYPE: 'past_hour_m25_earthquakes', + CONF_RADIUS: 200, + CONF_LATITUDE: 15.1, + CONF_LONGITUDE: 25.2 + } + ] +} + + +def _generate_mock_feed_entry(external_id, title, distance_to_home, + coordinates, place=None, + attribution=None, time=None, updated=None, + magnitude=None, status=None, + entry_type=None, alert=None): + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + feed_entry.place = place + feed_entry.attribution = attribution + feed_entry.time = time + feed_entry.updated = updated + feed_entry.magnitude = magnitude + feed_entry.status = status + feed_entry.type = entry_type + feed_entry.alert = alert + return feed_entry + + +async def test_setup(hass): + """Test the general setup of the platform.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 15.5, (-31.0, 150.0), + place='Location 1', attribution='Attribution 1', + time=datetime.datetime(2018, 9, 22, 8, 0, + tzinfo=datetime.timezone.utc), + updated=datetime.datetime(2018, 9, 22, 9, 0, + tzinfo=datetime.timezone.utc), + magnitude=5.7, status='Status 1', entry_type='Type 1', + alert='Alert 1') + mock_entry_2 = _generate_mock_feed_entry( + '2345', 'Title 2', 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry( + '3456', 'Title 3', 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry( + '4567', 'Title 4', 12.5, (-31.3, 150.3)) + + # Patching 'utcnow' to gain more control over the timed update. + utcnow = dt_util.utcnow() + with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \ + patch('geojson_client.usgs_earthquake_hazards_program_feed.' + 'UsgsEarthquakeHazardsProgramFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1, + mock_entry_2, + mock_entry_3] + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG) + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + state = hass.states.get("geo_location.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1", + ATTR_PLACE: "Location 1", + ATTR_ATTRIBUTION: "Attribution 1", + ATTR_TIME: + datetime.datetime( + 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc), + ATTR_UPDATED: + datetime.datetime( + 2018, 9, 22, 9, 0, tzinfo=datetime.timezone.utc), + ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1', + ATTR_ALERT: 'Alert 1', ATTR_MAGNITUDE: 5.7, + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-15.5), 7) == 0 + + state = hass.states.get("geo_location.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-20.5), 7) == 0 + + state = hass.states.get("geo_location.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: "km", + ATTR_SOURCE: 'usgs_earthquakes_feed'} + assert round(abs(float(state.state)-25.5), 7) == 0 + + # Simulate an update - one existing, one new entry, + # one outdated entry + mock_feed.return_value.update.return_value = 'OK', [ + mock_entry_1, mock_entry_4, mock_entry_3] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed.return_value.update.return_value = 'OK_NO_DATA', None + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 3 + + # Simulate an update - empty data, removes all entities + mock_feed.return_value.update.return_value = 'ERROR', None + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 0 + + +async def test_setup_with_custom_location(hass): + """Test the setup with a custom location.""" + # Set up some mock feed entries for this test. + mock_entry_1 = _generate_mock_feed_entry( + '1234', 'Title 1', 20.5, (-31.1, 150.1)) + + with patch('geojson_client.usgs_earthquake_hazards_program_feed.' + 'UsgsEarthquakeHazardsProgramFeed') as mock_feed: + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] + + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) + + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() + + all_states = hass.states.async_all() + assert len(all_states) == 1 + + assert mock_feed.call_args == call( + (15.1, 25.2), 'past_hour_m25_earthquakes', + filter_minimum_magnitude=0.0, filter_radius=200.0) diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 442660c2daf3ff..ae90af61ceda82 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -104,8 +104,14 @@ } +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + @pytest.fixture -def geofency_client(loop, hass, aiohttp_client): +def geofency_client(loop, hass, hass_client): """Geofency mock client.""" assert loop.run_until_complete(async_setup_component( hass, DOMAIN, { @@ -113,8 +119,10 @@ def geofency_client(loop, hass, aiohttp_client): CONF_MOBILE_BEACONS: ['Car 1'] }})) + loop.run_until_complete(hass.async_block_till_done()) + with patch('homeassistant.components.device_tracker.update_config'): - yield loop.run_until_complete(aiohttp_client(hass.http.app)) + yield loop.run_until_complete(hass_client()) @pytest.fixture(autouse=True) diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index 1568919a9b4957..03cc327a5c51f7 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -141,7 +141,10 @@ 'name': 'Bedroom' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -153,7 +156,10 @@ 'name': 'Living Room' }, 'traits': - ['action.devices.traits.OnOff', 'action.devices.traits.Brightness'], + [ + 'action.devices.traits.OnOff', 'action.devices.traits.Brightness', + 'action.devices.traits.Modes' + ], 'type': 'action.devices.types.SWITCH', 'willReportState': @@ -163,7 +169,7 @@ 'name': { 'name': 'Lounge room' }, - 'traits': ['action.devices.traits.OnOff'], + 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Modes'], 'type': 'action.devices.types.SWITCH', 'willReportState': False }, { @@ -225,7 +231,10 @@ 'name': { 'name': 'HeatPump' }, - 'traits': ['action.devices.traits.TemperatureSetting'], + 'traits': [ + 'action.devices.traits.OnOff', + 'action.devices.traits.TemperatureSetting' + ], 'type': 'action.devices.types.THERMOSTAT', 'willReportState': False }, { diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 047fad3574cecf..89e9090da98a78 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -204,6 +204,7 @@ def test_query_climate_request(hass_fixture, assistant_client, auth_header): devices = body['payload']['devices'] assert len(devices) == 3 assert devices['climate.heatpump'] == { + 'on': True, 'online': True, 'thermostatTemperatureSetpoint': 20.0, 'thermostatTemperatureAmbient': 25.0, @@ -260,6 +261,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client, auth_header): devices = body['payload']['devices'] assert len(devices) == 3 assert devices['climate.heatpump'] == { + 'on': True, 'online': True, 'thermostatTemperatureSetpoint': -6.7, 'thermostatTemperatureAmbient': -3.9, diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index 616c43464a6270..e9169c9bbbe16a 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -389,6 +389,47 @@ async def test_onoff_media_player(hass): } +async def test_onoff_climate(hass): + """Test OnOff trait support for climate domain.""" + assert trait.OnOffTrait.supported(climate.DOMAIN, climate.SUPPORT_ON_OFF) + + trt_on = trait.OnOffTrait(hass, State('climate.bla', STATE_ON), + BASIC_CONFIG) + + assert trt_on.sync_attributes() == {} + + assert trt_on.query_attributes() == { + 'on': True + } + + trt_off = trait.OnOffTrait(hass, State('climate.bla', STATE_OFF), + BASIC_CONFIG) + + assert trt_off.query_attributes() == { + 'on': False + } + + on_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_ON) + await trt_on.execute(trait.COMMAND_ONOFF, { + 'on': True + }) + assert len(on_calls) == 1 + assert on_calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + } + + off_calls = async_mock_service(hass, climate.DOMAIN, + SERVICE_TURN_OFF) + + await trt_on.execute(trait.COMMAND_ONOFF, { + 'on': False + }) + assert len(off_calls) == 1 + assert off_calls[0].data == { + ATTR_ENTITY_ID: 'climate.bla', + } + + async def test_dock_vacuum(hass): """Test dock trait support for vacuum domain.""" assert trait.DockTrait.supported(vacuum.DOMAIN, 0) @@ -876,3 +917,91 @@ async def test_fan_speed(hass): 'entity_id': 'fan.living_room_fan', 'speed': 'medium' } + + +async def test_modes(hass): + """Test Mode trait.""" + assert trait.ModesTrait.supported( + media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE) + + trt = trait.ModesTrait( + hass, State( + 'media_player.living_room', media_player.STATE_PLAYING, + attributes={ + media_player.ATTR_INPUT_SOURCE_LIST: [ + 'media', 'game', 'chromecast', 'plex' + ], + media_player.ATTR_INPUT_SOURCE: 'game' + }), + BASIC_CONFIG) + + attribs = trt.sync_attributes() + assert attribs == { + 'availableModes': [ + { + 'name': 'input source', + 'name_values': [ + { + 'name_synonym': ['input source'], + 'lang': 'en' + } + ], + 'settings': [ + { + 'setting_name': 'media', + 'setting_values': [ + { + 'setting_synonym': ['media', 'media mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'game', + 'setting_values': [ + { + 'setting_synonym': ['game', 'game mode'], + 'lang': 'en' + } + ] + }, + { + 'setting_name': 'chromecast', + 'setting_values': [ + { + 'setting_synonym': ['chromecast'], + 'lang': 'en' + } + ] + } + ], + 'ordered': False + } + ] + } + + assert trt.query_attributes() == { + 'currentModeSettings': {'source': 'game'}, + 'on': True, + 'online': True + } + + assert trt.can_execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + calls = async_mock_service( + hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) + await trt.execute( + trait.COMMAND_MODES, params={ + 'updateModeSettings': { + trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' + }}) + + assert len(calls) == 1 + assert calls[0].data == { + 'entity_id': 'media_player.living_room', + 'source': 'media' + } diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 4370c011891b8e..07db126312b0d1 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -102,15 +102,15 @@ def test_forward_request_no_auth_for_logo(hassio_client): @asyncio.coroutine def test_forward_log_request(hassio_client): - """Test fetching normal log path.""" + """Test fetching normal log path doesn't remove ANSI color escape codes.""" response = MagicMock() response.read.return_value = mock_coro('data') with patch('homeassistant.components.hassio.HassIOView._command_proxy', Mock(return_value=mock_coro(response))), \ patch('homeassistant.components.hassio.http.' - '_create_response_log') as mresp: - mresp.return_value = 'response' + '_create_response') as mresp: + mresp.return_value = '\033[32mresponse\033[0m' resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={ HTTP_HEADER_HA_AUTH: API_PASSWORD }) @@ -118,7 +118,7 @@ def test_forward_log_request(hassio_client): # Check we got right response assert resp.status == 200 body = yield from resp.text() - assert body == 'response' + assert body == '\033[32mresponse\033[0m' # Check we forwarded command assert len(mresp.mock_calls) == 1 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 51fca931faaaff..62e7278ba1fddb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -89,8 +89,7 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): async def test_setup_api_push_api_data_default(hass, aioclient_mock, hass_storage): """Test setup with API push default data.""" - with patch.dict(os.environ, MOCK_ENVIRON), \ - patch('homeassistant.auth.AuthManager.active', return_value=True): + with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} @@ -130,20 +129,6 @@ async def test_setup_adds_admin_group_to_user(hass, aioclient_mock, '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.""" with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, @@ -151,11 +136,7 @@ async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, }) assert result - assert aioclient_mock.call_count == 3 - assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] is None - assert aioclient_mock.mock_calls[1][2]['port'] == 8123 - assert aioclient_mock.mock_calls[1][2]['refresh_token'] is None + assert user.is_admin async def test_setup_api_existing_hassio_user(hass, aioclient_mock, @@ -169,8 +150,7 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, 'hassio_user': user.id } } - with patch.dict(os.environ, MOCK_ENVIRON), \ - patch('homeassistant.auth.AuthManager.active', return_value=True): + with patch.dict(os.environ, MOCK_ENVIRON): result = await async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 222e8ced6e7aa6..304bb4de997e01 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -75,19 +75,10 @@ async def test_auth_middleware_loaded_by_default(hass): assert len(mock_setup.mock_calls) == 1 -async def test_access_without_password(app, aiohttp_client): - """Test access without password.""" - setup_auth(app, [], False, api_password=None) - client = await aiohttp_client(app) - - resp = await client.get('/') - assert resp.status == 200 - - async def test_access_with_password_in_header(app, aiohttp_client, legacy_auth, hass): """Test access with password in header.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -107,7 +98,7 @@ async def test_access_with_password_in_header(app, aiohttp_client, async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, hass): """Test access with password in URL.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -131,7 +122,7 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" - setup_auth(app, [], False, api_password=API_PASSWORD) + setup_auth(app, [], api_password=API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -164,7 +155,7 @@ async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') + setup_auth(app2, TRUSTED_NETWORKS, api_password='some-pass') set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -190,7 +181,7 @@ async def test_auth_active_access_with_access_token_in_header( hass, app, aiohttp_client, hass_access_token): """Test access with access token in header.""" token = hass_access_token - setup_auth(app, [], True, api_password=None) + setup_auth(app, [], api_password=None) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) @@ -238,7 +229,7 @@ async def test_auth_active_access_with_access_token_in_header( async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) + setup_auth(app2, TRUSTED_NETWORKS, None) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -260,31 +251,10 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, } -async def test_auth_active_blocked_api_password_access( - app, aiohttp_client, legacy_auth): - """Test access using api_password should be blocked when auth.active.""" - setup_auth(app, [], True, api_password=API_PASSWORD) - client = await aiohttp_client(app) - - req = await client.get( - '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) - assert req.status == 401 - - resp = await client.get('/', params={ - 'api_password': API_PASSWORD - }) - assert resp.status == 401 - - req = await client.get( - '/', - auth=BasicAuth('homeassistant', API_PASSWORD)) - assert req.status == 401 - - async def test_auth_legacy_support_api_password_access( app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" - setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) + setup_auth(app, [], API_PASSWORD) client = await aiohttp_client(app) user = await legacy_api_password.async_get_user(hass) @@ -320,7 +290,7 @@ async def test_auth_access_signed_path( """Test access with signed url.""" app.router.add_post('/', mock_handler) app.router.add_get('/another_path', mock_handler) - setup_auth(app, [], True, api_password=None) + setup_auth(app, [], None) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index ac0e23edd64fc8..395849f066e8f2 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -1,8 +1,25 @@ """Tests for Home Assistant View.""" -from aiohttp.web_exceptions import HTTPInternalServerError +from unittest.mock import Mock + +from aiohttp.web_exceptions import ( + HTTPInternalServerError, HTTPBadRequest, HTTPUnauthorized) import pytest +import voluptuous as vol + +from homeassistant.components.http.view import ( + HomeAssistantView, request_handler_factory) +from homeassistant.exceptions import ServiceNotFound, Unauthorized + +from tests.common import mock_coro_func -from homeassistant.components.http.view import HomeAssistantView + +@pytest.fixture +def mock_request(): + """Mock a request.""" + return Mock( + app={'hass': Mock(is_running=True)}, + match_info={}, + ) async def test_invalid_json(caplog): @@ -10,6 +27,33 @@ async def test_invalid_json(caplog): view = HomeAssistantView() with pytest.raises(HTTPInternalServerError): - view.json(object) + view.json(float("NaN")) + + assert str(float("NaN")) in caplog.text + - assert str(object) in caplog.text +async def test_handling_unauthorized(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPUnauthorized): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=Unauthorized) + )(mock_request) + + +async def test_handling_invalid_data(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPBadRequest): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=vol.Invalid('yo')) + )(mock_request) + + +async def test_handling_service_not_found(mock_request): + """Test handling unauth exceptions.""" + with pytest.raises(HTTPInternalServerError): + await request_handler_factory( + Mock(requires_auth=False), + mock_coro_func(exception=ServiceNotFound('test', 'test')) + )(mock_request) diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index f09f3726252afd..9e4fa3ebc790f4 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -585,7 +585,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'effect': 'random', 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt.async_get_last_state', + with patch('homeassistant.helpers.restore_state.RestoreEntity' + '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, config) @@ -1063,3 +1064,56 @@ async def test_discovery_removal_light(hass, mqtt_mock, caplog): state = hass.states.get('light.beer') assert state is None + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test discovery of mqtt light with deprecated platform option.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt",' + ' "command_topic": "test_topic"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 03a3927472a33f..8567dfd7921986 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -93,18 +93,19 @@ from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light +from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start import homeassistant.core as ha -from tests.common import mock_coro, async_fire_mqtt_message +from tests.common import mock_coro, async_fire_mqtt_message, MockConfigEntry async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): """Test if setup fails with no command topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', } }) @@ -116,7 +117,8 @@ async def test_no_color_brightness_color_temp_white_val_if_no_topics( """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -152,7 +154,8 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): """Test the controlling of the state via topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -276,12 +279,13 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt_json' + with patch('homeassistant.helpers.restore_state.RestoreEntity' '.async_get_last_state', return_value=mock_coro(fake_state)): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'brightness': True, @@ -308,7 +312,8 @@ async def test_sending_hs_color(hass, mqtt_mock): """Test light.turn_on with hs color sends hs color parameters.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'hs': True, @@ -323,7 +328,8 @@ async def test_flash_short_and_long(hass, mqtt_mock): """Test for flash length being sent when included.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -342,7 +348,8 @@ async def test_transition(hass, mqtt_mock): """Test for transition time being sent when included.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -359,7 +366,8 @@ async def test_brightness_scale(hass, mqtt_mock): """Test for brightness scaling.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_bright_scale', 'command_topic': 'test_light_bright_scale/set', @@ -395,7 +403,8 @@ async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock): """Test that invalid color/brightness/white values are ignored.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -466,7 +475,8 @@ async def test_default_availability_payload(hass, mqtt_mock): """Test availability by default payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -495,7 +505,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): """Test availability by custom payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_json', + 'platform': 'mqtt', + 'schema': 'json', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -524,10 +535,11 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" - await async_start(hass, 'homeassistant', {'mqtt': {}}) + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) data = ( '{ "name": "Beer",' - ' "platform": "mqtt_json",' + ' "schema": "json",' ' "command_topic": "test_topic" }' ) async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', @@ -542,3 +554,58 @@ async def test_discovery_removal(hass, mqtt_mock, caplog): await hass.async_block_till_done() state = hass.states.get('light.beer') assert state is None + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test discovery of mqtt_json light with deprecated platform option.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt_json",' + ' "command_topic": "test_topic"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "schema": "json",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "json",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 6bc0b4536eaf7a..ce4a5f5a2e6391 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -31,11 +31,13 @@ from homeassistant.setup import async_setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) -import homeassistant.components.light as light +from homeassistant.components import light, mqtt +from homeassistant.components.mqtt.discovery import async_start import homeassistant.core as ha from tests.common import ( - async_fire_mqtt_message, assert_setup_component, mock_coro) + async_fire_mqtt_message, assert_setup_component, mock_coro, + MockConfigEntry) async def test_setup_fails(hass, mqtt_mock): @@ -43,19 +45,56 @@ async def test_setup_fails(hass, mqtt_mock): with assert_setup_component(0, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', } }) assert hass.states.get('light.test') is None + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + } + }) + assert hass.states.get('light.test') is None + + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + 'command_on_template': 'on', + } + }) + assert hass.states.get('light.test') is None + + with assert_setup_component(0, light.DOMAIN): + assert await async_setup_component(hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt', + 'schema': 'template', + 'name': 'test', + 'command_topic': 'test_topic', + 'command_off_template': 'off', + } + }) + assert hass.states.get('light.test') is None + async def test_state_change_via_topic(hass, mqtt_mock): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'state_topic': 'test_light_rgb', 'command_topic': 'test_light_rgb/set', @@ -96,7 +135,8 @@ async def test_state_brightness_color_effect_temp_white_change_via_topic( with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'effect_list': ['rainbow', 'colorloop'], 'state_topic': 'test_light_rgb', @@ -205,13 +245,14 @@ async def test_optimistic(hass, mqtt_mock): 'color_temp': 100, 'white_value': 50}) - with patch('homeassistant.components.light.mqtt_template' + with patch('homeassistant.helpers.restore_state.RestoreEntity' '.async_get_last_state', return_value=mock_coro(fake_state)): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,' @@ -243,7 +284,8 @@ async def test_flash(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ flash }}', @@ -261,7 +303,8 @@ async def test_transition(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -278,7 +321,8 @@ async def test_invalid_values(hass, mqtt_mock): with assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'effect_list': ['rainbow', 'colorloop'], 'state_topic': 'test_light_rgb', @@ -380,7 +424,8 @@ async def test_default_availability_payload(hass, mqtt_mock): """Test availability by default payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -410,7 +455,8 @@ async def test_custom_availability_payload(hass, mqtt_mock): """Test availability by custom payload with defined topic.""" assert await async_setup_component(hass, light.DOMAIN, { light.DOMAIN: { - 'platform': 'mqtt_template', + 'platform': 'mqtt', + 'schema': 'template', 'name': 'test', 'command_topic': 'test_light_rgb/set', 'command_on_template': 'on,{{ transition }}', @@ -436,3 +482,83 @@ async def test_custom_availability_payload(hass, mqtt_mock): state = hass.states.get('light.test') assert STATE_UNAVAILABLE == state.state + + +async def test_discovery(hass, mqtt_mock, caplog): + """Test removal of discovered mqtt_json lights.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + +async def test_discovery_deprecated(hass, mqtt_mock, caplog): + """Test discovery of mqtt template light with deprecated option.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {'mqtt': {}}, entry) + data = ( + '{ "name": "Beer",' + ' "platform": "mqtt_template",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data) + await hass.async_block_till_done() + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + +async def test_discovery_update_light(hass, mqtt_mock, caplog): + """Test removal of discovered light.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "schema": "template",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + data2 = ( + '{ "name": "Milk",' + ' "schema": "template",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic",' + ' "command_on_template": "on",' + ' "command_off_template": "off"}' + ) + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/light/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('light.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('light.milk') + assert state is None diff --git a/tests/components/lock/test_verisure.py b/tests/components/lock/test_verisure.py new file mode 100644 index 00000000000000..03dd202e8381cc --- /dev/null +++ b/tests/components/lock/test_verisure.py @@ -0,0 +1,141 @@ +"""Tests for the Verisure platform.""" + +from contextlib import contextmanager +from unittest.mock import patch, call +from homeassistant.const import STATE_UNLOCKED +from homeassistant.setup import async_setup_component +from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK) +from homeassistant.components.verisure import DOMAIN as VERISURE_DOMAIN + + +NO_DEFAULT_LOCK_CODE_CONFIG = { + 'verisure': { + 'username': 'test', + 'password': 'test', + 'locks': True, + 'alarm': False, + 'door_window': False, + 'hygrometers': False, + 'mouse': False, + 'smartplugs': False, + 'thermometers': False, + 'smartcam': False, + } +} + +DEFAULT_LOCK_CODE_CONFIG = { + 'verisure': { + 'username': 'test', + 'password': 'test', + 'locks': True, + 'default_lock_code': '9999', + 'alarm': False, + 'door_window': False, + 'hygrometers': False, + 'mouse': False, + 'smartplugs': False, + 'thermometers': False, + 'smartcam': False, + } +} + +LOCKS = ['door_lock'] + + +@contextmanager +def mock_hub(config, get_response=LOCKS[0]): + """Extensively mock out a verisure hub.""" + hub_prefix = 'homeassistant.components.lock.verisure.hub' + verisure_prefix = 'verisure.Session' + with patch(verisure_prefix) as session, \ + patch(hub_prefix) as hub: + session.login.return_value = True + + hub.config = config['verisure'] + hub.get.return_value = LOCKS + hub.get_first.return_value = get_response.upper() + hub.session.set_lock_state.return_value = { + 'doorLockStateChangeTransactionId': 'test', + } + hub.session.get_lock_state_transaction.return_value = { + 'result': 'OK', + } + + yield hub + + +async def setup_verisure_locks(hass, config): + """Set up mock verisure locks.""" + with mock_hub(config): + await async_setup_component(hass, VERISURE_DOMAIN, config) + await hass.async_block_till_done() + # lock.door_lock, group.all_locks + assert len(hass.states.async_all()) == 2 + + +async def test_verisure_no_default_code(hass): + """Test configs without a default lock code.""" + await setup_verisure_locks(hass, NO_DEFAULT_LOCK_CODE_CONFIG) + with mock_hub(NO_DEFAULT_LOCK_CODE_CONFIG, + STATE_UNLOCKED) as hub: + + mock = hub.session.set_lock_state + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_count == 0 + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'lock') + + mock.reset_mock() + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_count == 0 + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'unlock') + + +async def test_verisure_default_code(hass): + """Test configs with a default lock code.""" + await setup_verisure_locks(hass, DEFAULT_LOCK_CODE_CONFIG) + with mock_hub(DEFAULT_LOCK_CODE_CONFIG, STATE_UNLOCKED) as hub: + mock = hub.session.set_lock_state + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_args == call('9999', LOCKS[0], 'lock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + }) + await hass.async_block_till_done() + assert mock.call_args == call('9999', LOCKS[0], 'unlock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'lock') + + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, { + 'entity_id': 'lock.door_lock', + 'code': '12345', + }) + await hass.async_block_till_done() + assert mock.call_args == call('12345', LOCKS[0], 'unlock') diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index e296d14c6f8885..15548b28cfb3a8 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -1,748 +1,98 @@ """Test the Lovelace initialization.""" from unittest.mock import patch -from ruamel.yaml import YAML -from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.components.lovelace import migrate_config -from homeassistant.util.ruamel_yaml import UnsupportedYamlError - -TEST_YAML_A = """\ -title: My Awesome Home -# Include external resources -resources: - - url: /local/my-custom-card.js - type: js - - url: /local/my-webfont.css - type: css - -# Exclude entities from "Unused entities" view -excluded_entities: - - weblink.router -views: - # View tab title. - - title: Example - # Optional unique id for direct access /lovelace/${id} - id: example - # Optional background (overwrites the global background). - background: radial-gradient(crimson, skyblue) - # Each view can have a different theme applied. - theme: dark-mode - # The cards to show on this view. - cards: - # The filter card will filter entities for their state - - type: entity-filter - entities: - - device_tracker.paulus - - device_tracker.anne_there - state_filter: - - 'home' - card: - type: glance - title: People that are home - - # The picture entity card will represent an entity with a picture - - type: picture-entity - image: https://www.home-assistant.io/images/default-social.png - entity: light.bed_light - - # Specify a tab icon if you want the view tab to be an icon. - - icon: mdi:home-assistant - # Title of the view. Will be used as the tooltip for tab icon - title: Second view - cards: - - id: test - type: entities - title: Test card - # Entities card will take a list of entities and show their state. - - type: entities - # Title of the entities card - title: Example - # The entities here will be shown in the same order as specified. - # Each entry is an entity ID or a map with extra options. - entities: - - light.kitchen - - switch.ac - - entity: light.living_room - # Override the name to use - name: LR Lights - - # The markdown card will render markdown text. - - type: markdown - title: Lovelace - content: > - Welcome to your **Lovelace UI**. -""" - -TEST_YAML_B = """\ -title: Home -views: - - title: Dashboard - id: dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack - cards: - - type: picture-entity - entity: group.sample - name: Sample - image: /local/images/sample.jpg - tap_action: toggle -""" - -# Test data that can not be loaded as YAML -TEST_BAD_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: - - id: testid - type: vertical-stack -""" - -# Test unsupported YAML -TEST_UNSUP_YAML = """\ -title: Home -views: - - title: Dashboard - icon: mdi:home - cards: !include cards.yaml -""" - - -def test_add_id(): - """Test if id is added.""" - yaml = YAML(typ='rt') - - fname = "dummy.yaml" - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - migrate_config(fname) - - result = save_yaml_mock.call_args_list[0][0][1] - assert 'id' in result['views'][0]['cards'][0] - assert 'id' in result['views'][1] - - -def test_id_not_changed(): - """Test if id is not changed if already exists.""" - yaml = YAML(typ='rt') - - fname = "dummy.yaml" - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_B)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - migrate_config(fname) - assert save_yaml_mock.call_count == 0 - - -async def test_deprecated_lovelace_ui(hass, hass_ws_client): - """Test lovelace_ui command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - return_value={'hello': 'world'}): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} - - -async def test_deprecated_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'file_not_found' - - -async def test_deprecated_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'frontend/lovelace_config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_ui(hass, hass_ws_client): - """Test lovelace_ui command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - return_value={'hello': 'world'}): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} - - -async def test_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'file_not_found' - - -async def test_lovelace_ui_load_err(hass, hass_ws_client): - """Test lovelace_ui command load error.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_ui_load_json_err(hass, hass_ws_client): - """Test lovelace_ui command load error.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.components.lovelace.load_config', - side_effect=UnsupportedYamlError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'unsupported_error' - - -async def test_lovelace_get_card(hass, hass_ws_client): - """Test get_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'test', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == 'id: test\ntype: entities\ntitle: Test card\n' - - -async def test_lovelace_get_card_not_found(hass, hass_ws_client): - """Test get_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'not_found', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'card_not_found' - - -async def test_lovelace_get_card_bad_yaml(hass, hass_ws_client): - """Test get_card command bad yaml.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/get', - 'card_id': 'testid', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_update_card(hass, hass_ws_client): - """Test update_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'test', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'cards', 0, 'type'], - list_ok=True) == 'glance' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_update_card_not_found(hass, hass_ws_client): - """Test update_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'not_found', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'card_not_found' - - -async def test_lovelace_update_card_bad_yaml(hass, hass_ws_client): - """Test update_card command bad yaml.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.yaml_to_object', - side_effect=HomeAssistantError): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/update', - 'card_id': 'test', - 'card_config': 'id: test\ntype: glance\n', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'error' - - -async def test_lovelace_add_card(hass, hass_ws_client): - """Test add_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/add', - 'view_id': 'example', - 'card_config': 'id: test\ntype: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 2, 'type'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_card_position(hass, hass_ws_client): - """Test add_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/add', - 'view_id': 'example', - 'position': 0, - 'card_config': 'id: test\ntype: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 0, 'type'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_position(hass, hass_ws_client): - """Test move_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_position': 2, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'cards', 2, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_view(hass, hass_ws_client): - """Test move_card to view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_view_id': 'example', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 2, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_card_view_position(hass, hass_ws_client): - """Test move_card to view with position command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/move', - 'card_id': 'test', - 'new_view_id': 'example', - 'new_position': 1, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'cards', 1, 'title'], - list_ok=True) == 'Test card' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_delete_card(hass, hass_ws_client): - """Test delete_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/card/delete', - 'card_id': 'test', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - cards = result.mlget(['views', 1, 'cards'], list_ok=True) - assert len(cards) == 2 - assert cards[0]['title'] == 'Example' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_get_view(hass, hass_ws_client): - """Test get_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/get', - 'view_id': 'example', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert "".join(msg['result'].split()) == "".join('title: Example\n # \ - Optional unique id for direct\ - access /lovelace/${id}\nid: example\n # Optional\ - background (overwrites the global background).\n\ - background: radial-gradient(crimson, skyblue)\n\ - # Each view can have a different theme applied.\n\ - theme: dark-mode\n'.split()) - - -async def test_lovelace_get_view_not_found(hass, hass_ws_client): - """Test get_card command cannot find card.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)): - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/get', - 'view_id': 'not_found', - }) - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] is False - assert msg['error']['code'] == 'view_not_found' - - -async def test_lovelace_update_view(hass, hass_ws_client): - """Test update_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - origyaml = yaml.load(TEST_YAML_A) - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=origyaml), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/update', - 'view_id': 'example', - 'view_config': 'id: example2\ntitle: New title\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - orig_view = origyaml.mlget(['views', 0], list_ok=True) - new_view = result.mlget(['views', 0], list_ok=True) - assert new_view['title'] == 'New title' - assert new_view['cards'] == orig_view['cards'] - assert 'theme' not in new_view - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_view(hass, hass_ws_client): - """Test add_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/add', - 'view_config': 'id: test\ntitle: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 2, 'title'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_add_view_position(hass, hass_ws_client): - """Test add_view command with position.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/add', - 'position': 0, - 'view_config': 'id: test\ntitle: added\n', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 0, 'title'], - list_ok=True) == 'added' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_move_view_position(hass, hass_ws_client): - """Test move_view command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/move', - 'view_id': 'example', - 'new_position': 1, - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - assert result.mlget(['views', 1, 'title'], - list_ok=True) == 'Example' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - - -async def test_lovelace_delete_view(hass, hass_ws_client): - """Test delete_card command.""" - await async_setup_component(hass, 'lovelace') - client = await hass_ws_client(hass) - yaml = YAML(typ='rt') - - with patch('homeassistant.util.ruamel_yaml.load_yaml', - return_value=yaml.load(TEST_YAML_A)), \ - patch('homeassistant.util.ruamel_yaml.save_yaml') \ - as save_yaml_mock: - await client.send_json({ - 'id': 5, - 'type': 'lovelace/config/view/delete', - 'view_id': 'example', - }) - msg = await client.receive_json() - - result = save_yaml_mock.call_args_list[0][0][1] - views = result.get('views', []) - assert len(views) == 1 - assert views[0]['title'] == 'Second view' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] +from homeassistant.components import frontend, lovelace + + +async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): + """Test we load lovelace config from storage.""" + assert await async_setup_component(hass, 'lovelace', {}) + assert hass.data[frontend.DATA_PANELS]['lovelace'].config == { + 'mode': 'storage' + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert not response['success'] + assert response['error']['code'] == 'config_not_found' + + # Store new config + await client.send_json({ + 'id': 6, + 'type': 'lovelace/config/save', + 'config': { + 'yo': 'hello' + } + }) + response = await client.receive_json() + assert response['success'] + assert hass_storage[lovelace.STORAGE_KEY]['data'] == { + 'config': {'yo': 'hello'} + } + + # Load new config + await client.send_json({ + 'id': 7, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert response['success'] + + assert response['result'] == { + 'yo': 'hello' + } + + +async def test_lovelace_from_yaml(hass, hass_ws_client): + """Test we load lovelace config from yaml.""" + assert await async_setup_component(hass, 'lovelace', { + 'lovelace': { + 'mode': 'YAML' + } + }) + assert hass.data[frontend.DATA_PANELS]['lovelace'].config == { + 'mode': 'yaml' + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({ + 'id': 5, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + assert not response['success'] + + assert response['error']['code'] == 'config_not_found' + + # Store new config not allowed + await client.send_json({ + 'id': 6, + 'type': 'lovelace/config/save', + 'config': { + 'yo': 'hello' + } + }) + response = await client.receive_json() + assert not response['success'] + + # Patch data + with patch('homeassistant.components.lovelace.load_yaml', return_value={ + 'hello': 'yo' + }): + await client.send_json({ + 'id': 7, + 'type': 'lovelace/config' + }) + response = await client.receive_json() + + assert response['success'] + assert response['result'] == {'hello': 'yo'} diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py index 2c69a5effa7efc..de0ee2f0b3ea5a 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -9,7 +9,7 @@ @pytest.fixture -def mock_http_client(hass, aiohttp_client): +def mock_http_client(hass, hass_client): """Start the Hass HTTP component.""" config = { mailbox.DOMAIN: { @@ -18,7 +18,7 @@ def mock_http_client(hass, aiohttp_client): } hass.loop.run_until_complete( async_setup_component(hass, mailbox.DOMAIN, config)) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return hass.loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/media_player/common.py b/tests/components/media_player/common.py index 3f4d4cb9f241e0..2174967eae53f9 100644 --- a/tests/components/media_player/common.py +++ b/tests/components/media_player/common.py @@ -11,9 +11,9 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, - SERVICE_MEDIA_SEEK, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, - SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, - SERVICE_VOLUME_UP) + SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, + SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, SERVICE_VOLUME_UP) from homeassistant.loader import bind_hass @@ -95,6 +95,13 @@ def media_pause(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data) +@bind_hass +def media_stop(hass, entity_id=None): + """Send the media player the command for stop.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + hass.services.call(DOMAIN, SERVICE_MEDIA_STOP, data) + + @bind_hass def media_next_track(hass, entity_id=None): """Send the media player the command for next track.""" diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py index e986ac02065845..b213cf0b5c195e 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,6 +3,9 @@ from unittest.mock import patch import asyncio +import pytest +import voluptuous as vol + from homeassistant.setup import setup_component from homeassistant.const import HTTP_HEADER_HA_AUTH import homeassistant.components.media_player as mp @@ -43,7 +46,8 @@ def test_source_select(self): state = self.hass.states.get(entity_id) assert 'dvd' == state.attributes.get('source') - common.select_source(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.select_source(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 'dvd' == state.attributes.get('source') @@ -72,7 +76,8 @@ def test_volume_services(self): state = self.hass.states.get(entity_id) assert 1.0 == state.attributes.get('volume_level') - common.set_volume_level(self.hass, None, entity_id) + with pytest.raises(vol.Invalid): + common.set_volume_level(self.hass, None, entity_id) self.hass.block_till_done() state = self.hass.states.get(entity_id) assert 1.0 == state.attributes.get('volume_level') @@ -201,7 +206,8 @@ def test_play_media(self, mock_seek): state.attributes.get('supported_features')) assert state.attributes.get('media_content_id') is not None - common.play_media(self.hass, None, 'some_id', ent_id) + with pytest.raises(vol.Invalid): + common.play_media(self.hass, None, 'some_id', ent_id) self.hass.block_till_done() state = self.hass.states.get(ent_id) assert 0 < (mp.SUPPORT_PLAY_MEDIA & @@ -216,7 +222,8 @@ def test_play_media(self, mock_seek): assert 'some_id' == state.attributes.get('media_content_id') assert not mock_seek.called - common.media_seek(self.hass, None, ent_id) + with pytest.raises(vol.Invalid): + common.media_seek(self.hass, None, ent_id) self.hass.block_till_done() assert not mock_seek.called common.media_seek(self.hass, 100, ent_id) diff --git a/tests/components/media_player/test_directv.py b/tests/components/media_player/test_directv.py new file mode 100644 index 00000000000000..951f1319cc0272 --- /dev/null +++ b/tests/components/media_player/test_directv.py @@ -0,0 +1,535 @@ +"""The tests for the DirecTV Media player platform.""" +from unittest.mock import call, patch + +from datetime import datetime, timedelta +import requests +import pytest + +import homeassistant.components.media_player as mp +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_ENQUEUE, DOMAIN, + SERVICE_PLAY_MEDIA) +from homeassistant.components.media_player.directv import ( + ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, + ATTR_MEDIA_START_TIME, DEFAULT_DEVICE, DEFAULT_PORT) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, + SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, + SERVICE_TURN_ON, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE) +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import MockDependency, async_fire_time_changed + +CLIENT_ENTITY_ID = 'media_player.client_dvr' +MAIN_ENTITY_ID = 'media_player.main_dvr' +IP_ADDRESS = '127.0.0.1' + +DISCOVERY_INFO = { + 'host': IP_ADDRESS, + 'serial': 1234 +} + +LIVE = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": False, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home" +} + +LOCATIONS = [ + { + 'locationName': 'Main DVR', + 'clientAddr': DEFAULT_DEVICE + } +] + +RECORDING = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": True, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home", + 'uniqueId': '12345', + 'episodeTitle': 'Configure DirecTV platform.' +} + +WORKING_CONFIG = { + 'media_player': { + 'platform': 'directv', + CONF_HOST: IP_ADDRESS, + CONF_NAME: 'Main DVR', + CONF_PORT: DEFAULT_PORT, + CONF_DEVICE: DEFAULT_DEVICE + } +} + + +@pytest.fixture +def client_dtv(): + """Fixture for a client device.""" + mocked_dtv = MockDirectvClass('mock_ip') + mocked_dtv.attributes = RECORDING + mocked_dtv._standby = False + return mocked_dtv + + +@pytest.fixture +def main_dtv(): + """Fixture for main DVR.""" + return MockDirectvClass('mock_ip') + + +@pytest.fixture +def dtv_side_effect(client_dtv, main_dtv): + """Fixture to create DIRECTV instance for main and client.""" + def mock_dtv(ip, port, client_addr): + if client_addr != '0': + mocked_dtv = client_dtv + else: + mocked_dtv = main_dtv + mocked_dtv._host = ip + mocked_dtv._port = port + mocked_dtv._device = client_addr + return mocked_dtv + return mock_dtv + + +@pytest.fixture +def mock_now(): + """Fixture for dtutil.now.""" + return dt_util.utcnow() + + +@pytest.fixture +def platforms(hass, dtv_side_effect, mock_now): + """Fixture for setting up test platforms.""" + config = { + 'media_player': [{ + 'platform': 'directv', + 'name': 'Main DVR', + 'host': IP_ADDRESS, + 'port': DEFAULT_PORT, + 'device': DEFAULT_DEVICE + }, { + 'platform': 'directv', + 'name': 'Client DVR', + 'host': IP_ADDRESS, + 'port': DEFAULT_PORT, + 'device': '1' + }] + } + + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', side_effect=dtv_side_effect), \ + patch('homeassistant.util.dt.utcnow', return_value=mock_now): + hass.loop.run_until_complete(async_setup_component( + hass, mp.DOMAIN, config)) + hass.loop.run_until_complete(hass.async_block_till_done()) + yield + + +async def async_turn_on(hass, entity_id=None): + """Turn on specified media player or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) + + +async def async_turn_off(hass, entity_id=None): + """Turn off specified media player or all.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + + +async def async_media_pause(hass, entity_id=None): + """Send the media player the command for pause.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PAUSE, data) + + +async def async_media_play(hass, entity_id=None): + """Send the media player the command for play/pause.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PLAY, data) + + +async def async_media_stop(hass, entity_id=None): + """Send the media player the command for stop.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_STOP, data) + + +async def async_media_next_track(hass, entity_id=None): + """Send the media player the command for next track.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) + + +async def async_media_previous_track(hass, entity_id=None): + """Send the media player the command for prev track.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) + + +async def async_play_media(hass, media_type, media_id, entity_id=None, + enqueue=None): + """Send the media player the command for playing media.""" + data = {ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + if enqueue: + data[ATTR_MEDIA_ENQUEUE] = enqueue + + await hass.services.async_call(DOMAIN, SERVICE_PLAY_MEDIA, data) + + +class MockDirectvClass: + """A fake DirecTV DVR device.""" + + def __init__(self, ip, port=8080, clientAddr='0'): + """Initialize the fake DirecTV device.""" + self._host = ip + self._port = port + self._device = clientAddr + self._standby = True + self._play = False + + self._locations = LOCATIONS + + self.attributes = LIVE + + def get_locations(self): + """Mock for get_locations method.""" + test_locations = { + 'locations': self._locations, + 'status': { + 'code': 200, + 'commandResult': 0, + 'msg': 'OK.', + 'query': '/info/getLocations' + } + } + + return test_locations + + def get_standby(self): + """Mock for get_standby method.""" + return self._standby + + def get_tuned(self): + """Mock for get_tuned method.""" + if self._play: + self.attributes['offset'] = self.attributes['offset']+1 + + test_attributes = self.attributes + test_attributes['status'] = { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/tv/getTuned" + } + return test_attributes + + def key_press(self, keypress): + """Mock for key_press method.""" + if keypress == 'poweron': + self._standby = False + self._play = True + elif keypress == 'poweroff': + self._standby = True + self._play = False + elif keypress == 'play': + self._play = True + elif keypress == 'pause' or keypress == 'stop': + self._play = False + + def tune_channel(self, source): + """Mock for tune_channel method.""" + self.attributes['major'] = int(source) + + +async def test_setup_platform_config(hass): + """Test setting up the platform from configuration.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover(hass): + """Test setting up the platform from discovery.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover_duplicate(hass): + """Test setting up the platform from discovery.""" + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state + assert len(hass.states.async_entity_ids('media_player')) == 1 + + +async def test_setup_platform_discover_client(hass): + """Test setting up the platform from discovery.""" + LOCATIONS.append({ + 'locationName': 'Client 1', + 'clientAddr': '1' + }) + LOCATIONS.append({ + 'locationName': 'Client 2', + 'clientAddr': '2' + }) + + with MockDependency('DirectPy'), \ + patch('DirectPy.DIRECTV', new=MockDirectvClass): + + await async_setup_component(hass, mp.DOMAIN, WORKING_CONFIG) + await hass.async_block_till_done() + + hass.async_create_task( + async_load_platform(hass, mp.DOMAIN, 'directv', DISCOVERY_INFO, + {'media_player': {}}) + ) + await hass.async_block_till_done() + + del LOCATIONS[-1] + del LOCATIONS[-1] + state = hass.states.get(MAIN_ENTITY_ID) + assert state + state = hass.states.get('media_player.client_1') + assert state + state = hass.states.get('media_player.client_2') + assert state + + assert len(hass.states.async_entity_ids('media_player')) == 3 + + +async def test_supported_features(hass, platforms): + """Test supported features.""" + # Features supported for main DVR + state = hass.states.get(MAIN_ENTITY_ID) + assert mp.SUPPORT_PAUSE | mp.SUPPORT_TURN_ON | mp.SUPPORT_TURN_OFF |\ + mp.SUPPORT_PLAY_MEDIA | mp.SUPPORT_STOP | mp.SUPPORT_NEXT_TRACK |\ + mp.SUPPORT_PREVIOUS_TRACK | mp.SUPPORT_PLAY ==\ + state.attributes.get('supported_features') + + # Feature supported for clients. + state = hass.states.get(CLIENT_ENTITY_ID) + assert mp.SUPPORT_PAUSE |\ + mp.SUPPORT_PLAY_MEDIA | mp.SUPPORT_STOP | mp.SUPPORT_NEXT_TRACK |\ + mp.SUPPORT_PREVIOUS_TRACK | mp.SUPPORT_PLAY ==\ + state.attributes.get('supported_features') + + +async def test_check_attributes(hass, platforms, mock_now): + """Test attributes.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + # Start playing TV + with patch('homeassistant.util.dt.utcnow', + return_value=next_update): + await async_media_play(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(CLIENT_ENTITY_ID) + assert state.state == STATE_PLAYING + + assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_ID) == \ + RECORDING['programId'] + assert state.attributes.get(mp.ATTR_MEDIA_CONTENT_TYPE) == \ + mp.MEDIA_TYPE_TVSHOW + assert state.attributes.get(mp.ATTR_MEDIA_DURATION) == \ + RECORDING['duration'] + assert state.attributes.get(mp.ATTR_MEDIA_POSITION) == 2 + assert state.attributes.get( + mp.ATTR_MEDIA_POSITION_UPDATED_AT) == next_update + assert state.attributes.get(mp.ATTR_MEDIA_TITLE) == RECORDING['title'] + assert state.attributes.get(mp.ATTR_MEDIA_SERIES_TITLE) == \ + RECORDING['episodeTitle'] + assert state.attributes.get(mp.ATTR_MEDIA_CHANNEL) == \ + "{} ({})".format(RECORDING['callsign'], RECORDING['major']) + assert state.attributes.get(mp.ATTR_INPUT_SOURCE) == RECORDING['major'] + assert state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) == \ + RECORDING['isRecording'] + assert state.attributes.get(ATTR_MEDIA_RATING) == RECORDING['rating'] + assert state.attributes.get(ATTR_MEDIA_RECORDED) + assert state.attributes.get(ATTR_MEDIA_START_TIME) == \ + datetime(2018, 11, 10, 19, 0, tzinfo=dt_util.UTC) + + # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not + # updated if TV is paused. + with patch('homeassistant.util.dt.utcnow', + return_value=next_update + timedelta(minutes=5)): + await async_media_pause(hass, CLIENT_ENTITY_ID) + await hass.async_block_till_done() + + state = hass.states.get(CLIENT_ENTITY_ID) + assert state.state == STATE_PAUSED + assert state.attributes.get( + mp.ATTR_MEDIA_POSITION_UPDATED_AT) == next_update + + +async def test_main_services(hass, platforms, main_dtv, mock_now): + """Test the different services.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + # DVR starts in off state. + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_OFF + + # All these should call key_press in our class. + with patch.object(main_dtv, 'key_press', + wraps=main_dtv.key_press) as mock_key_press, \ + patch.object(main_dtv, 'tune_channel', + wraps=main_dtv.tune_channel) as mock_tune_channel, \ + patch.object(main_dtv, 'get_tuned', + wraps=main_dtv.get_tuned) as mock_get_tuned, \ + patch.object(main_dtv, 'get_standby', + wraps=main_dtv.get_standby) as mock_get_standby: + + # Turn main DVR on. When turning on DVR is playing. + await async_turn_on(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('poweron') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING + + # Pause live TV. + await async_media_pause(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('pause') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED + + # Start play again for live TV. + await async_media_play(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('play') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING + + # Change channel, currently it should be 202 + assert state.attributes.get('source') == 202 + await async_play_media(hass, 'channel', 7, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_tune_channel.called + assert mock_tune_channel.call_args == call('7') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.attributes.get('source') == 7 + + # Stop live TV. + await async_media_stop(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('stop') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED + + # Turn main DVR off. + await async_turn_off(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + assert mock_key_press.called + assert mock_key_press.call_args == call('poweroff') + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_OFF + + # There should have been 6 calls to check if DVR is in standby + assert main_dtv.get_standby.call_count == 6 + assert mock_get_standby.call_count == 6 + # There should be 5 calls to get current info (only 1 time it will + # not be called as DVR is in standby.) + assert main_dtv.get_tuned.call_count == 5 + assert mock_get_tuned.call_count == 5 + + +async def test_available(hass, platforms, main_dtv, mock_now): + """Test available status.""" + next_update = mock_now + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + # Confirm service is currently set to available. + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE + + # Make update fail (i.e. DVR offline) + next_update = next_update + timedelta(minutes=5) + with patch.object( + main_dtv, 'get_standby', side_effect=requests.RequestException), \ + patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_UNAVAILABLE + + # Recheck state, update should work again. + next_update = next_update + timedelta(minutes=5) + with patch('homeassistant.util.dt.utcnow', return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 417cd42187fa84..c6a6b3036d97fa 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -223,7 +223,7 @@ def test_service_calls_with_entity_id(self): # Restoring wrong media player to its previous state # Nothing should be done self.hass.services.call(DOMAIN, SERVICE_RESTORE, - {'entity_id': 'not_existing'}, + {'entity_id': 'media.not_existing'}, blocking=True) # self.hass.block_till_done() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5d7afbde8432c6..81e6a7b298d44e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -113,11 +113,12 @@ def test_service_call_with_payload_doesnt_render_template(self): """ payload = "not a template" payload_template = "a template" - self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, { - mqtt.ATTR_TOPIC: "test/topic", - mqtt.ATTR_PAYLOAD: payload, - mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template - }, blocking=True) + with pytest.raises(vol.Invalid): + self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, { + mqtt.ATTR_TOPIC: "test/topic", + mqtt.ATTR_PAYLOAD: payload, + mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template + }, blocking=True) assert not self.hass.data['mqtt'].async_publish.called def test_service_call_with_ascii_qos_retain_flags(self): diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 57397e21ba2cc2..4c3f3bf3f73ef6 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -2,6 +2,9 @@ import unittest from unittest.mock import patch +import pytest +import voluptuous as vol + import homeassistant.components.notify as notify from homeassistant.setup import setup_component from homeassistant.components.notify import demo @@ -81,7 +84,8 @@ def record_calls(self, *args): def test_sending_none_message(self): """Test send with None as message.""" self._setup_notify() - common.send_message(self.hass, None) + with pytest.raises(vol.Invalid): + common.send_message(self.hass, None) self.hass.block_till_done() assert len(self.events) == 0 diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 486300679b7414..08210ecd9a2e9d 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -49,7 +49,7 @@ PUBLISH_URL = '/api/notify.html5/callback' -async def mock_client(hass, aiohttp_client, registrations=None): +async def mock_client(hass, hass_client, registrations=None): """Create a test client for HTML5 views.""" if registrations is None: registrations = {} @@ -62,7 +62,7 @@ async def mock_client(hass, aiohttp_client, registrations=None): } }) - return await aiohttp_client(hass.http.app) + return await hass_client() class TestHtml5Notify: @@ -151,9 +151,9 @@ def test_gcm_key_include(self, mock_wp): assert mock_wp.mock_calls[4][2]['gcm_key'] is None -async def test_registering_new_device_view(hass, aiohttp_client): +async def test_registering_new_device_view(hass, hass_client): """Test that the HTML view works.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -165,9 +165,9 @@ async def test_registering_new_device_view(hass, aiohttp_client): } -async def test_registering_new_device_expiration_view(hass, aiohttp_client): +async def test_registering_new_device_expiration_view(hass, hass_client): """Test that the HTML view works.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4)) @@ -178,10 +178,10 @@ async def test_registering_new_device_expiration_view(hass, aiohttp_client): } -async def test_registering_new_device_fails_view(hass, aiohttp_client): +async def test_registering_new_device_fails_view(hass, hass_client): """Test subs. are not altered when registering a new device fails.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -191,10 +191,10 @@ async def test_registering_new_device_fails_view(hass, aiohttp_client): assert registrations == {} -async def test_registering_existing_device_view(hass, aiohttp_client): +async def test_registering_existing_device_view(hass, hass_client): """Test subscription is updated when registering existing device.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -209,10 +209,10 @@ async def test_registering_existing_device_view(hass, aiohttp_client): } -async def test_registering_existing_device_fails_view(hass, aiohttp_client): +async def test_registering_existing_device_fails_view(hass, hass_client): """Test sub. is not updated when registering existing device fails.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1)) @@ -225,9 +225,9 @@ async def test_registering_existing_device_fails_view(hass, aiohttp_client): } -async def test_registering_new_device_validation(hass, aiohttp_client): +async def test_registering_new_device_validation(hass, hass_client): """Test various errors when registering a new device.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) resp = await client.post(REGISTER_URL, data=json.dumps({ 'browser': 'invalid browser', @@ -249,13 +249,13 @@ async def test_registering_new_device_validation(hass, aiohttp_client): assert resp.status == 400 -async def test_unregistering_device_view(hass, aiohttp_client): +async def test_unregistering_device_view(hass, hass_client): """Test that the HTML unregister view works.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -270,10 +270,10 @@ async def test_unregistering_device_view(hass, aiohttp_client): async def test_unregister_device_view_handle_unknown_subscription( - hass, aiohttp_client): + hass, hass_client): """Test that the HTML unregister view handles unknown subscriptions.""" registrations = {} - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json') as mock_save: resp = await client.delete(REGISTER_URL, data=json.dumps({ @@ -286,13 +286,13 @@ async def test_unregister_device_view_handle_unknown_subscription( async def test_unregistering_device_view_handles_save_error( - hass, aiohttp_client): + hass, hass_client): """Test that the HTML unregister view handles save errors.""" registrations = { 'some device': SUBSCRIPTION_1, 'other device': SUBSCRIPTION_2, } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('homeassistant.components.notify.html5.save_json', side_effect=HomeAssistantError()): @@ -307,23 +307,23 @@ async def test_unregistering_device_view_handles_save_error( } -async def test_callback_view_no_jwt(hass, aiohttp_client): +async def test_callback_view_no_jwt(hass, hass_client): """Test that the notification callback view works without JWT.""" - client = await mock_client(hass, aiohttp_client) + client = await mock_client(hass, hass_client) resp = await client.post(PUBLISH_URL, data=json.dumps({ 'type': 'push', 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72' })) - assert resp.status == 401, resp.response + assert resp.status == 401 -async def test_callback_view_with_jwt(hass, aiohttp_client): +async def test_callback_view_with_jwt(hass, hass_client): """Test that the notification callback view works with JWT.""" registrations = { 'device': SUBSCRIPTION_1 } - client = await mock_client(hass, aiohttp_client, registrations) + client = await mock_client(hass, hass_client, registrations) with patch('pywebpush.WebPusher') as mock_wp: await hass.services.async_call('notify', 'notify', { diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py index 57a81a78da34b7..483b917a63e419 100644 --- a/tests/components/onboarding/test_init.py +++ b/tests/components/onboarding/test_init.py @@ -38,8 +38,7 @@ async def test_setup_views_if_not_onboarded(hass): assert len(mock_setup.mock_calls) == 1 assert onboarding.DOMAIN in hass.data - with patch('homeassistant.auth.AuthManager.active', return_value=True): - assert not onboarding.async_is_onboarded(hass) + assert not onboarding.async_is_onboarded(hass) async def test_is_onboarded(): @@ -47,17 +46,13 @@ async def test_is_onboarded(): hass = Mock() hass.data = {} - with patch('homeassistant.auth.AuthManager.active', return_value=False): - assert onboarding.async_is_onboarded(hass) - - with patch('homeassistant.auth.AuthManager.active', return_value=True): - assert onboarding.async_is_onboarded(hass) + assert onboarding.async_is_onboarded(hass) - hass.data[onboarding.DOMAIN] = True - assert onboarding.async_is_onboarded(hass) + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) - hass.data[onboarding.DOMAIN] = False - assert not onboarding.async_is_onboarded(hass) + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) async def test_having_owner_finishes_user_step(hass, hass_storage): diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py index ee79c8b9e10be7..3d2d8d03e7c156 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -33,6 +33,12 @@ } +@pytest.fixture(autouse=True) +def mock_dev_track(mock_device_tracker_conf): + """Mock device tracker config loading.""" + pass + + @pytest.fixture def mock_client(hass, aiohttp_client): """Start the Hass HTTP component.""" @@ -104,7 +110,7 @@ def test_handle_value_error(mock_client): @asyncio.coroutine -def test_returns_error_missing_username(mock_client): +def test_returns_error_missing_username(mock_client, caplog): """Test that an error is returned when username is missing.""" resp = yield from mock_client.post( '/api/webhook/owntracks_test', @@ -114,10 +120,29 @@ def test_returns_error_missing_username(mock_client): } ) - assert resp.status == 400 + # Needs to be 200 or OwnTracks keeps retrying bad packet. + assert resp.status == 200 + json = yield from resp.json() + assert json == [] + assert 'No topic or user found' in caplog.text + +@asyncio.coroutine +def test_returns_error_incorrect_json(mock_client, caplog): + """Test that an error is returned when username is missing.""" + resp = yield from mock_client.post( + '/api/webhook/owntracks_test', + data='not json', + headers={ + 'X-Limit-d': 'Pixel', + } + ) + + # Needs to be 200 or OwnTracks keeps retrying bad packet. + assert resp.status == 200 json = yield from resp.json() - assert json == {'error': 'You need to supply username.'} + assert json == [] + assert 'invalid JSON' in caplog.text @asyncio.coroutine diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 93da4ec109bd87..d008f868466d85 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -1,6 +1,5 @@ """The tests for the Recorder component.""" # pylint: disable=protected-access -import asyncio from unittest.mock import patch, call import pytest @@ -9,7 +8,7 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components.recorder import ( - wait_connection_ready, migration, const, models) + migration, const, models) from tests.components.recorder import models_original @@ -23,26 +22,24 @@ def create_engine_test(*args, **kwargs): return engine -@asyncio.coroutine -def test_schema_update_calls(hass): +async def test_schema_update_calls(hass): """Test that schema migrations occur in correct order.""" with patch('sqlalchemy.create_engine', new=create_engine_test), \ patch('homeassistant.components.recorder.migration._apply_update') as \ update: - yield from async_setup_component(hass, 'recorder', { + await async_setup_component(hass, 'recorder', { 'recorder': { 'db_url': 'sqlite://' } }) - yield from wait_connection_ready(hass) + await hass.async_block_till_done() update.assert_has_calls([ call(hass.data[const.DATA_INSTANCE].engine, version+1, 0) for version in range(0, models.SCHEMA_VERSION)]) -@asyncio.coroutine -def test_schema_migrate(hass): +async def test_schema_migrate(hass): """Test the full schema migration logic. We're just testing that the logic can execute successfully here without @@ -52,12 +49,12 @@ def test_schema_migrate(hass): with patch('sqlalchemy.create_engine', new=create_engine_test), \ patch('homeassistant.components.recorder.Recorder._setup_run') as \ setup_run: - yield from async_setup_component(hass, 'recorder', { + await async_setup_component(hass, 'recorder', { 'recorder': { 'db_url': 'sqlite://' } }) - yield from wait_connection_ready(hass) + await hass.async_block_till_done() assert setup_run.called diff --git a/tests/components/sensor/test_awair.py b/tests/components/sensor/test_awair.py new file mode 100644 index 00000000000000..b539bdbfe7da8c --- /dev/null +++ b/tests/components/sensor/test_awair.py @@ -0,0 +1,282 @@ +"""Tests for the Awair sensor platform.""" + +from contextlib import contextmanager +from datetime import timedelta +import json +import logging +from unittest.mock import patch + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor.awair import ( + ATTR_LAST_API_UPDATE, ATTR_TIMESTAMP, DEVICE_CLASS_CARBON_DIOXIDE, + DEVICE_CLASS_PM2_5, DEVICE_CLASS_SCORE, + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS) +from homeassistant.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, + TEMP_CELSIUS) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import parse_datetime, utcnow + +from tests.common import async_fire_time_changed, load_fixture, mock_coro + +DISCOVERY_CONFIG = { + 'sensor': { + 'platform': 'awair', + 'access_token': 'qwerty', + } +} + +MANUAL_CONFIG = { + 'sensor': { + 'platform': 'awair', + 'access_token': 'qwerty', + 'devices': [ + {'uuid': 'awair_foo'} + ] + } +} + +_LOGGER = logging.getLogger(__name__) + +NOW = utcnow() +AIR_DATA_FIXTURE = json.loads(load_fixture('awair_air_data_latest.json')) +AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] = str(NOW) +AIR_DATA_FIXTURE_UPDATED = json.loads( + load_fixture('awair_air_data_latest_updated.json')) +AIR_DATA_FIXTURE_UPDATED[0][ATTR_TIMESTAMP] = str(NOW + timedelta(minutes=5)) + + +@contextmanager +def alter_time(retval): + """Manage multiple time mocks.""" + patch_one = patch('homeassistant.util.dt.utcnow', return_value=retval) + patch_two = patch('homeassistant.util.utcnow', return_value=retval) + patch_three = patch('homeassistant.components.sensor.awair.dt.utcnow', + return_value=retval) + + with patch_one, patch_two, patch_three: + yield + + +async def setup_awair(hass, config=None): + """Load the Awair platform.""" + devices_json = json.loads(load_fixture('awair_devices.json')) + devices_mock = mock_coro(devices_json) + devices_patch = patch('python_awair.AwairClient.devices', + return_value=devices_mock) + air_data_mock = mock_coro(AIR_DATA_FIXTURE) + air_data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=air_data_mock) + + if config is None: + config = DISCOVERY_CONFIG + + with devices_patch, air_data_patch, alter_time(NOW): + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + +async def test_platform_manually_configured(hass): + """Test that we can manually configure devices.""" + await setup_awair(hass, MANUAL_CONFIG) + + assert len(hass.states.async_all()) == 6 + + # Ensure that we loaded the device with uuid 'awair_foo', not the + # 'awair_12345' device that we stub out for API device discovery + entity = hass.data[SENSOR_DOMAIN].get_entity('sensor.awair_co2') + assert entity.unique_id == 'awair_foo_CO2' + + +async def test_platform_automatically_configured(hass): + """Test that we can discover devices from the API.""" + await setup_awair(hass) + + assert len(hass.states.async_all()) == 6 + + # Ensure that we loaded the device with uuid 'awair_12345', which is + # the device that we stub out for API device discovery + entity = hass.data[SENSOR_DOMAIN].get_entity('sensor.awair_co2') + assert entity.unique_id == 'awair_12345_CO2' + + +async def test_bad_platform_setup(hass): + """Tests that we throw correct exceptions when setting up Awair.""" + from python_awair import AwairClient + + auth_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.AuthError) + rate_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.RatelimitError) + generic_patch = patch('python_awair.AwairClient.devices', + side_effect=AwairClient.GenericError) + + with auth_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with rate_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with generic_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + +async def test_awair_misc_attributes(hass): + """Test that desired attributes are set.""" + await setup_awair(hass) + + attributes = hass.states.get('sensor.awair_co2').attributes + assert (attributes[ATTR_LAST_API_UPDATE] == + parse_datetime(AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP])) + + +async def test_awair_score(hass): + """Test that we create a sensor for the 'Awair score'.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_score') + assert sensor.state == '78' + assert sensor.attributes['device_class'] == DEVICE_CLASS_SCORE + assert sensor.attributes['unit_of_measurement'] == '%' + + +async def test_awair_temp(hass): + """Test that we create a temperature sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_temperature') + assert sensor.state == '22.4' + assert sensor.attributes['device_class'] == DEVICE_CLASS_TEMPERATURE + assert sensor.attributes['unit_of_measurement'] == TEMP_CELSIUS + + +async def test_awair_humid(hass): + """Test that we create a humidity sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_humidity') + assert sensor.state == '32.73' + assert sensor.attributes['device_class'] == DEVICE_CLASS_HUMIDITY + assert sensor.attributes['unit_of_measurement'] == '%' + + +async def test_awair_co2(hass): + """Test that we create a CO2 sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_co2') + assert sensor.state == '612' + assert sensor.attributes['device_class'] == DEVICE_CLASS_CARBON_DIOXIDE + assert sensor.attributes['unit_of_measurement'] == 'ppm' + + +async def test_awair_voc(hass): + """Test that we create a CO2 sensor.""" + await setup_awair(hass) + + sensor = hass.states.get('sensor.awair_voc') + assert sensor.state == '1012' + assert (sensor.attributes['device_class'] == + DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS) + assert sensor.attributes['unit_of_measurement'] == 'ppb' + + +async def test_awair_dust(hass): + """Test that we create a pm25 sensor.""" + await setup_awair(hass) + + # The Awair Gen1 that we mock actually returns 'DUST', but that + # is mapped to pm25 internally so that it shows up in Homekit + sensor = hass.states.get('sensor.awair_pm25') + assert sensor.state == '6.2' + assert sensor.attributes['device_class'] == DEVICE_CLASS_PM2_5 + assert sensor.attributes['unit_of_measurement'] == 'µg/m3' + + +async def test_awair_unsupported_sensors(hass): + """Ensure we don't create sensors the stubbed device doesn't support.""" + await setup_awair(hass) + + # Our tests mock an Awair Gen 1 device, which should never return + # PM10 sensor readings. Assert that we didn't create a pm10 sensor, + # which could happen if someone were ever to refactor incorrectly. + assert hass.states.get('sensor.awair_pm10') is None + + +async def test_availability(hass): + """Ensure that we mark the component available/unavailable correctly.""" + await setup_awair(hass) + + assert hass.states.get('sensor.awair_score').state == '78' + + future = NOW + timedelta(minutes=30) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == STATE_UNAVAILABLE + + future = NOW + timedelta(hours=1) + fixture = AIR_DATA_FIXTURE_UPDATED + fixture[0][ATTR_TIMESTAMP] = str(future) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(fixture)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '79' + + +async def test_async_update(hass): + """Ensure we can update sensors.""" + await setup_awair(hass) + + future = NOW + timedelta(minutes=10) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + score_sensor = hass.states.get('sensor.awair_score') + assert score_sensor.state == '79' + + assert hass.states.get('sensor.awair_temperature').state == '23.4' + assert hass.states.get('sensor.awair_humidity').state == '33.73' + assert hass.states.get('sensor.awair_co2').state == '613' + assert hass.states.get('sensor.awair_voc').state == '1013' + assert hass.states.get('sensor.awair_pm25').state == '7.2' + + +async def test_throttle_async_update(hass): + """Ensure we throttle updates.""" + await setup_awair(hass) + + future = NOW + timedelta(minutes=1) + data_patch = patch('python_awair.AwairClient.air_data_latest', + return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED)) + + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '78' + + future = NOW + timedelta(minutes=15) + with data_patch, alter_time(future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get('sensor.awair_score').state == '79' diff --git a/tests/components/sensor/test_bom.py b/tests/components/sensor/test_bom.py index 50669f5a77d02d..fc2722f9742b51 100644 --- a/tests/components/sensor/test_bom.py +++ b/tests/components/sensor/test_bom.py @@ -6,11 +6,12 @@ from urllib.parse import urlparse import requests -from tests.common import ( - assert_setup_component, get_test_home_assistant, load_fixture) from homeassistant.components import sensor +from homeassistant.components.sensor.bom import BOMCurrentData from homeassistant.setup import setup_component +from tests.common import ( + assert_setup_component, get_test_home_assistant, load_fixture) VALID_CONFIG = { 'platform': 'bom', @@ -97,3 +98,12 @@ def test_sensor_values(self, mock_get): feels_like = self.hass.states.get('sensor.bom_fake_feels_like_c').state assert '25.0' == feels_like + + +class TestBOMCurrentData(unittest.TestCase): + """Test the BOM data container.""" + + def test_should_update_initial(self): + """Test that the first update always occurs.""" + bom_data = BOMCurrentData('IDN60901.94767') + assert bom_data.should_update() is True diff --git a/tests/components/sensor/test_entur_public_transport.py b/tests/components/sensor/test_entur_public_transport.py new file mode 100644 index 00000000000000..20b50ce9ddd3ad --- /dev/null +++ b/tests/components/sensor/test_entur_public_transport.py @@ -0,0 +1,66 @@ +"""The tests for the entur platform.""" +from datetime import datetime +import unittest +from unittest.mock import patch + +from enturclient.api import RESOURCE +from enturclient.consts import ATTR_EXPECTED_AT, ATTR_ROUTE, ATTR_STOP_ID +import requests_mock + +from homeassistant.components.sensor.entur_public_transport import ( + CONF_EXPAND_PLATFORMS, CONF_STOP_IDS) +from homeassistant.setup import setup_component +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant, load_fixture + +VALID_CONFIG = { + 'platform': 'entur_public_transport', + CONF_EXPAND_PLATFORMS: False, + CONF_STOP_IDS: [ + 'NSR:StopPlace:548', + 'NSR:Quay:48550', + ] +} + +FIXTURE_FILE = 'entur_public_transport.json' +TEST_TIMESTAMP = datetime(2018, 10, 10, 7, tzinfo=dt_util.UTC) + + +class TestEnturPublicTransportSensor(unittest.TestCase): + """Test the entur platform.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + @patch( + 'homeassistant.components.sensor.entur_public_transport.dt_util.now', + return_value=TEST_TIMESTAMP) + def test_setup(self, mock_req, mock_patch): + """Test for correct sensor setup with state and proper attributes.""" + mock_req.post(RESOURCE, + text=load_fixture(FIXTURE_FILE), + status_code=200) + self.assertTrue( + setup_component(self.hass, 'sensor', {'sensor': VALID_CONFIG})) + + state = self.hass.states.get('sensor.entur_bergen_stasjon') + assert state.state == '28' + assert state.attributes.get(ATTR_STOP_ID) == 'NSR:StopPlace:548' + assert state.attributes.get(ATTR_ROUTE) == "59 Bergen" + assert state.attributes.get(ATTR_EXPECTED_AT) \ + == '2018-10-10T09:28:00+0200' + + state = self.hass.states.get('sensor.entur_fiskepiren_platform_2') + assert state.state == '0' + assert state.attributes.get(ATTR_STOP_ID) == 'NSR:Quay:48550' + assert state.attributes.get(ATTR_ROUTE) \ + == "5 Stavanger Airport via Forum" + assert state.attributes.get(ATTR_EXPECTED_AT) \ + == '2018-10-10T09:00:00+0200' diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py index 15042805a66f95..78de05e1ff38f6 100644 --- a/tests/components/sensor/test_mqtt.py +++ b/tests/components/sensor/test_mqtt.py @@ -412,6 +412,39 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_sensor(hass, mqtt_mock, caplog): + """Test removal of discovered sensor.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic" }' + ) + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('sensor.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('sensor.beer') + assert state is not None + assert state.name == 'Milk' + + state = hass.states.get('sensor.milk') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT sensor device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 5d1137c35e6952..9b4e53dbab9f09 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -40,7 +40,7 @@ def teardown_method(self, method): def test_binary_sensor_source(self): """Test if source is a sensor.""" - values = [1, 0, 1, 0, 1, 0, 1] + values = ['on', 'off', 'on', 'off', 'on', 'off', 'on'] assert setup_component(self.hass, 'sensor', { 'sensor': { 'platform': 'statistics', @@ -305,6 +305,7 @@ def mock_purge(self): 'max_age': {'hours': max_age} } }) + self.hass.block_till_done() self.hass.start() self.hass.block_till_done() diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index 5cdd7d230637da..a37572cc99228f 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -1,9 +1,9 @@ """The tests for the MQTT switch platform.""" import json -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE,\ ATTR_ASSUMED_STATE import homeassistant.core as ha @@ -11,279 +11,298 @@ from homeassistant.components.mqtt.discovery import async_start from tests.common import ( - mock_mqtt_component, fire_mqtt_message, get_test_home_assistant, mock_coro, - async_mock_mqtt_component, async_fire_mqtt_message, MockConfigEntry) + mock_coro, async_mock_mqtt_component, async_fire_mqtt_message, + MockConfigEntry) from tests.components.switch import common -class TestSwitchMQTT(unittest.TestCase): - """Test the MQTT switch.""" +@pytest.fixture +def mock_publish(hass): + """Initialize components.""" + yield hass.loop.run_until_complete(async_mock_mqtt_component(hass)) - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.mock_publish = mock_mqtt_component(self.hass) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() +async def test_controlling_state_via_topic(hass, mock_publish): + """Test the controlling state via topic.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 1, + 'payload_off': 0 + } + }) + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + async_fire_mqtt_message(hass, 'state-topic', '0') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - def test_controlling_state_via_topic(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'payload_on': 1, - 'payload_off': 0 - } - }) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) - - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() - - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state - - fire_mqtt_message(self.hass, 'state-topic', '0') - self.hass.block_till_done() - - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - - def test_sending_mqtt_commands_and_optimistic(self): - """Test the sending MQTT commands in optimistic mode.""" - fake_state = ha.State('switch.test', 'on') - - with patch('homeassistant.components.switch.mqtt.async_get_last_state', - return_value=mock_coro(fake_state)): - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'command_topic': 'command-topic', - 'payload_on': 'beer on', - 'payload_off': 'beer off', - 'qos': '2' - } - }) - - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state - assert state.attributes.get(ATTR_ASSUMED_STATE) - - common.turn_on(self.hass, 'switch.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'command-topic', 'beer on', 2, False) - self.mock_publish.async_publish.reset_mock() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state - - common.turn_off(self.hass, 'switch.test') - self.hass.block_till_done() - - self.mock_publish.async_publish.assert_called_once_with( - 'command-topic', 'beer off', 2, False) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - - def test_controlling_state_via_topic_and_json_message(self): - """Test the controlling state via topic and JSON message.""" - assert setup_component(self.hass, switch.DOMAIN, { +async def test_sending_mqtt_commands_and_optimistic(hass, mock_publish): + """Test the sending MQTT commands in optimistic mode.""" + fake_state = ha.State('switch.test', 'on') + + with patch('homeassistant.helpers.restore_state.RestoreEntity' + '.async_get_last_state', + return_value=mock_coro(fake_state)): + assert await async_setup_component(hass, switch.DOMAIN, { switch.DOMAIN: { 'platform': 'mqtt', 'name': 'test', - 'state_topic': 'state-topic', 'command_topic': 'command-topic', 'payload_on': 'beer on', 'payload_off': 'beer off', - 'value_template': '{{ value_json.val }}' + 'qos': '2' } }) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state + assert state.attributes.get(ATTR_ASSUMED_STATE) - fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer on"}') - self.hass.block_till_done() + common.turn_on(hass, 'switch.test') + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer on', 2, False) + mock_publish.async_publish.reset_mock() + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer off"}') - self.hass.block_till_done() + common.turn_off(hass, 'switch.test') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + mock_publish.async_publish.assert_called_once_with( + 'command-topic', 'beer off', 2, False) + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - def test_controlling_availability(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0, - 'payload_available': 1, - 'payload_not_available': 0 - } - }) - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state +async def test_controlling_state_via_topic_and_json_message( + hass, mock_publish): + """Test the controlling state via topic and JSON message.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 'beer on', + 'payload_off': 'beer off', + 'value_template': '{{ value_json.val }}' + } + }) - fire_mqtt_message(self.hass, 'availability_topic', '1') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer on"}') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', '0') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_ON == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer off"}') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_OFF == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', '1') - self.hass.block_till_done() +async def test_controlling_availability(hass, mock_publish): + """Test the controlling state via topic.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0, + 'payload_available': 1, + 'payload_not_available': 0 + } + }) - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - def test_default_availability_payload(self): - """Test the availability payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0 - } - }) + async_fire_mqtt_message(hass, 'availability_topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'availability_topic', '0') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'offline') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state +async def test_default_availability_payload(hass, mock_publish): + """Test the availability payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0 + } + }) - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - def test_custom_availability_payload(self): - """Test the availability payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'availability_topic': 'availability_topic', - 'payload_on': 1, - 'payload_off': 0, - 'payload_available': 'good', - 'payload_not_available': 'nogood' - } - }) + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'availability_topic', 'offline') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'good') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'availability_topic', 'nogood') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_ON == state.state - fire_mqtt_message(self.hass, 'state-topic', '1') - self.hass.block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state +async def test_custom_availability_payload(hass, mock_publish): + """Test the availability payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'availability_topic': 'availability_topic', + 'payload_on': 1, + 'payload_off': 0, + 'payload_available': 'good', + 'payload_not_available': 'nogood' + } + }) - fire_mqtt_message(self.hass, 'availability_topic', 'good') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() - def test_custom_state_payload(self): - """Test the state payload.""" - assert setup_component(self.hass, switch.DOMAIN, { - switch.DOMAIN: { - 'platform': 'mqtt', - 'name': 'test', - 'state_topic': 'state-topic', - 'command_topic': 'command-topic', - 'payload_on': 1, - 'payload_off': 0, - 'state_on': "HIGH", - 'state_off': "LOW", - } - }) + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state - assert not state.attributes.get(ATTR_ASSUMED_STATE) + async_fire_mqtt_message(hass, 'availability_topic', 'nogood') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'state-topic', 'HIGH') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'state-topic', 'LOW') - self.hass.block_till_done() + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + +async def test_custom_state_payload(hass, mock_publish): + """Test the state payload.""" + assert await async_setup_component(hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 1, + 'payload_off': 0, + 'state_on': "HIGH", + 'state_off': "LOW", + } + }) + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, 'state-topic', 'HIGH') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_ON == state.state + + async_fire_mqtt_message(hass, 'state-topic', 'LOW') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_OFF == state.state async def test_unique_id(hass): @@ -307,6 +326,7 @@ async def test_unique_id(hass): async_fire_mqtt_message(hass, 'test-topic', 'payload') await hass.async_block_till_done() + await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 2 # all switches group is 1, unique id created is 1 @@ -326,6 +346,7 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', data) await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('switch.beer') assert state is not None @@ -340,6 +361,42 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog): assert state is None +async def test_discovery_update_switch(hass, mqtt_mock, caplog): + """Test expansion of discovered switch.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, 'homeassistant', {}, entry) + + data1 = ( + '{ "name": "Beer",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + data2 = ( + '{ "name": "Milk",' + ' "status_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data1) + await hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is not None + assert state.name == 'Beer' + + async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config', + data2) + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.beer') + assert state is not None + assert state.name == 'Milk' + state = hass.states.get('switch.milk') + assert state is None + + async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT switch device registry integration.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/test_alert.py b/tests/components/test_alert.py index 76610421563611..9fda58c37a3487 100644 --- a/tests/components/test_alert.py +++ b/tests/components/test_alert.py @@ -99,6 +99,7 @@ class TestAlert(unittest.TestCase): def setUp(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self._setup_notify() def tearDown(self): """Stop everything that was started.""" diff --git a/tests/components/test_api.py b/tests/components/test_api.py index 0bc89292855e12..a88c828efe8ecd 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -6,6 +6,7 @@ from aiohttp import web import pytest +import voluptuous as vol from homeassistant import const from homeassistant.bootstrap import DATA_LOGGING @@ -578,3 +579,29 @@ async def test_rendering_template_legacy_user( json={"template": '{{ states.sensor.temperature.state }}'} ) assert resp.status == 401 + + +async def test_api_call_service_not_found(hass, mock_api_client): + """Test if the API failes 400 if unknown service.""" + resp = await mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format( + "test_domain", "test_service")) + assert resp.status == 400 + + +async def test_api_call_service_bad_data(hass, mock_api_client): + """Test if the API failes 400 if unknown service.""" + test_value = [] + + @ha.callback + def listener(service_call): + """Record that our service got called.""" + test_value.append(1) + + hass.services.async_register("test_domain", "test_service", listener, + schema=vol.Schema({'hello': str})) + + resp = await mock_api_client.post( + const.URL_API_SERVICES_SERVICE.format( + "test_domain", "test_service"), json={'hello': 5}) + assert resp.status == 400 diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index b9f63922ba3358..f1ef6aa5dd03c9 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -1,115 +1,114 @@ """The tests device sun light trigger component.""" # pylint: disable=protected-access from datetime import datetime -import unittest -from unittest.mock import patch +from asynctest import patch +import pytest -from homeassistant.setup import setup_component +from homeassistant.setup import async_setup_component import homeassistant.loader as loader from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.components import ( device_tracker, light, device_sun_light_trigger) from homeassistant.util import dt as dt_util -from tests.common import get_test_home_assistant, fire_time_changed +from tests.common import async_fire_time_changed from tests.components.light import common as common_light -class TestDeviceSunLightTrigger(unittest.TestCase): - """Test the device sun light trigger module.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - self.scanner = loader.get_component( - self.hass, 'device_tracker.test').get_scanner(None, None) - - self.scanner.reset() - self.scanner.come_home('DEV1') - - loader.get_component(self.hass, 'light.test').init() - - with patch( - 'homeassistant.components.device_tracker.load_yaml_config_file', - return_value={ - 'device_1': { - 'hide_if_away': False, - 'mac': 'DEV1', - 'name': 'Unnamed Device', - 'picture': 'http://example.com/dev1.jpg', - 'track': True, - 'vendor': None - }, - 'device_2': { - 'hide_if_away': False, - 'mac': 'DEV2', - 'name': 'Unnamed Device', - 'picture': 'http://example.com/dev2.jpg', - 'track': True, - 'vendor': None} - }): - assert setup_component(self.hass, device_tracker.DOMAIN, { +@pytest.fixture +def scanner(hass): + """Initialize components.""" + scanner = loader.get_component( + hass, 'device_tracker.test').get_scanner(None, None) + + scanner.reset() + scanner.come_home('DEV1') + + loader.get_component(hass, 'light.test').init() + + with patch( + 'homeassistant.components.device_tracker.load_yaml_config_file', + return_value={ + 'device_1': { + 'hide_if_away': False, + 'mac': 'DEV1', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev1.jpg', + 'track': True, + 'vendor': None + }, + 'device_2': { + 'hide_if_away': False, + 'mac': 'DEV2', + 'name': 'Unnamed Device', + 'picture': 'http://example.com/dev2.jpg', + 'track': True, + 'vendor': None} + }): + assert hass.loop.run_until_complete(async_setup_component( + hass, device_tracker.DOMAIN, { device_tracker.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - assert setup_component(self.hass, light.DOMAIN, { + assert hass.loop.run_until_complete(async_setup_component( + hass, light.DOMAIN, { light.DOMAIN: {CONF_PLATFORM: 'test'} - }) + })) - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() + return scanner - def test_lights_on_when_sun_sets(self): - """Test lights go on when there is someone home and the sun sets.""" - test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) - common_light.turn_off(self.hass) +async def test_lights_on_when_sun_sets(hass, scanner): + """Test lights go on when there is someone home and the sun sets.""" + test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) - self.hass.block_till_done() + common_light.async_turn_off(hass) - test_time = test_time.replace(hour=3) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - fire_time_changed(self.hass, test_time) - self.hass.block_till_done() + await hass.async_block_till_done() - assert light.is_on(self.hass) + test_time = test_time.replace(hour=3) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + async_fire_time_changed(hass, test_time) + await hass.async_block_till_done() - def test_lights_turn_off_when_everyone_leaves(self): - """Test lights turn off when everyone leaves the house.""" - common_light.turn_on(self.hass) + assert light.is_on(hass) - self.hass.block_till_done() - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) +async def test_lights_turn_off_when_everyone_leaves(hass, scanner): + """Test lights turn off when everyone leaves the house.""" + common_light.async_turn_on(hass) + + await hass.async_block_till_done() - self.hass.states.set(device_tracker.ENTITY_ID_ALL_DEVICES, - STATE_NOT_HOME) + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) - self.hass.block_till_done() + hass.states.async_set(device_tracker.ENTITY_ID_ALL_DEVICES, + STATE_NOT_HOME) - assert not light.is_on(self.hass) + await hass.async_block_till_done() - def test_lights_turn_on_when_coming_home_after_sun_set(self): - """Test lights turn on when coming home after sun set.""" - test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) - with patch('homeassistant.util.dt.utcnow', return_value=test_time): - common_light.turn_off(self.hass) - self.hass.block_till_done() + assert not light.is_on(hass) - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { - device_sun_light_trigger.DOMAIN: {}}) - self.hass.states.set( - device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) +async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): + """Test lights turn on when coming home after sun set.""" + test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) + with patch('homeassistant.util.dt.utcnow', return_value=test_time): + common_light.async_turn_off(hass) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, device_sun_light_trigger.DOMAIN, { + device_sun_light_trigger.DOMAIN: {}}) + + hass.states.async_set( + device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME) - self.hass.block_till_done() - assert light.is_on(self.hass) + await hass.async_block_till_done() + assert light.is_on(hass) diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 641dff3b4e6309..0c9062414e769c 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -519,7 +519,6 @@ async def test_fetch_period_api(hass, hass_client): """Test the fetch period view for history.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'history', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) client = await hass_client() response = await client.get( diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py index 5de6e164750997..d74ec41b7497af 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -3,8 +3,6 @@ import unittest from unittest import mock -import influxdb as influx_client - from homeassistant.setup import setup_component import homeassistant.components.influxdb as influxdb from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, \ @@ -48,7 +46,7 @@ def test_setup_config_full(self, mock_client): assert self.hass.bus.listen.called assert \ EVENT_STATE_CHANGED == self.hass.bus.listen.call_args_list[0][0][0] - assert mock_client.return_value.query.called + assert mock_client.return_value.write_points.call_count == 1 def test_setup_config_defaults(self, mock_client): """Test the setup with default configuration.""" @@ -82,20 +80,7 @@ def test_setup_missing_password(self, mock_client): assert not setup_component(self.hass, influxdb.DOMAIN, config) - def test_setup_query_fail(self, mock_client): - """Test the setup for query failures.""" - config = { - 'influxdb': { - 'host': 'host', - 'username': 'user', - 'password': 'pass', - } - } - mock_client.return_value.query.side_effect = \ - influx_client.exceptions.InfluxDBClientError('fake') - assert not setup_component(self.hass, influxdb.DOMAIN, config) - - def _setup(self, **kwargs): + def _setup(self, mock_client, **kwargs): """Set up the client.""" config = { 'influxdb': { @@ -111,10 +96,11 @@ def _setup(self, **kwargs): config['influxdb'].update(kwargs) assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() def test_event_listener(self, mock_client): """Test the event listener.""" - self._setup() + self._setup(mock_client) # map of HA State to valid influxdb [state, value] fields valid = { @@ -176,7 +162,7 @@ def test_event_listener(self, mock_client): def test_event_listener_no_units(self, mock_client): """Test the event listener for missing units.""" - self._setup() + self._setup(mock_client) for unit in (None, ''): if unit: @@ -207,7 +193,7 @@ def test_event_listener_no_units(self, mock_client): def test_event_listener_inf(self, mock_client): """Test the event listener for missing units.""" - self._setup() + self._setup(mock_client) attrs = {'bignumstring': '9' * 999, 'nonumstring': 'nan'} state = mock.MagicMock( @@ -234,7 +220,7 @@ def test_event_listener_inf(self, mock_client): def test_event_listener_states(self, mock_client): """Test the event listener against ignored states.""" - self._setup() + self._setup(mock_client) for state_state in (1, 'unknown', '', 'unavailable'): state = mock.MagicMock( @@ -264,7 +250,7 @@ def test_event_listener_states(self, mock_client): def test_event_listener_blacklist(self, mock_client): """Test the event listener against a blacklist.""" - self._setup() + self._setup(mock_client) for entity_id in ('ok', 'blacklisted'): state = mock.MagicMock( @@ -294,7 +280,7 @@ def test_event_listener_blacklist(self, mock_client): def test_event_listener_blacklist_domain(self, mock_client): """Test the event listener against a blacklist.""" - self._setup() + self._setup(mock_client) for domain in ('ok', 'another_fake'): state = mock.MagicMock( @@ -337,6 +323,7 @@ def test_event_listener_whitelist(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for entity_id in ('included', 'default'): state = mock.MagicMock( @@ -378,6 +365,7 @@ def test_event_listener_whitelist_domain(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for domain in ('fake', 'another_fake'): state = mock.MagicMock( @@ -408,7 +396,7 @@ def test_event_listener_whitelist_domain(self, mock_client): def test_event_listener_invalid_type(self, mock_client): """Test the event listener when an attribute has an invalid type.""" - self._setup() + self._setup(mock_client) # map of HA State to valid influxdb [state, value] fields valid = { @@ -470,6 +458,7 @@ def test_event_listener_default_measurement(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() for entity_id in ('ok', 'blacklisted'): state = mock.MagicMock( @@ -509,6 +498,7 @@ def test_event_listener_unit_of_measurement_field(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() attrs = { 'unit_of_measurement': 'foobars', @@ -548,6 +538,7 @@ def test_event_listener_tags_attributes(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() attrs = { 'friendly_fake': 'tag_str', @@ -604,6 +595,7 @@ def test_event_listener_component_override_measurement(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() test_components = [ {'domain': 'sensor', 'id': 'fake_humidity', 'res': 'humidity'}, @@ -647,6 +639,7 @@ def test_scheduled_write(self, mock_client): } assert setup_component(self.hass, influxdb.DOMAIN, config) self.handler_method = self.hass.bus.listen.call_args_list[0][0][1] + mock_client.return_value.write_points.reset_mock() state = mock.MagicMock( state=1, domain='fake', entity_id='entity.id', object_id='entity', @@ -674,7 +667,7 @@ def test_scheduled_write(self, mock_client): def test_queue_backlog_full(self, mock_client): """Test the event listener to drop old events.""" - self._setup() + self._setup(mock_client) state = mock.MagicMock( state=1, domain='fake', entity_id='entity.id', object_id='entity', diff --git a/tests/components/test_input_datetime.py b/tests/components/test_input_datetime.py index a61cefe34f2f6d..2a4d0fef09de6b 100644 --- a/tests/components/test_input_datetime.py +++ b/tests/components/test_input_datetime.py @@ -3,6 +3,9 @@ import asyncio import datetime +import pytest +import voluptuous as vol + from homeassistant.core import CoreState, State, Context from homeassistant.setup import async_setup_component from homeassistant.components.input_datetime import ( @@ -109,10 +112,11 @@ def test_set_invalid(hass): dt_obj = datetime.datetime(2017, 9, 7, 19, 46) time_portion = dt_obj.time() - yield from hass.services.async_call('input_datetime', 'set_datetime', { - 'entity_id': 'test_date', - 'time': time_portion - }) + with pytest.raises(vol.Invalid): + yield from hass.services.async_call('input_datetime', 'set_datetime', { + 'entity_id': 'test_date', + 'time': time_portion + }) yield from hass.async_block_till_done() state = hass.states.get(entity_id) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 0d204773241add..321a16ae64e5a1 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -4,12 +4,16 @@ from datetime import (timedelta, datetime) import unittest +import pytest +import voluptuous as vol + from homeassistant.components import sun import homeassistant.core as ha from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SERVICE, + ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_NAME, EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF) + EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, ATTR_HIDDEN, + STATE_NOT_HOME, STATE_ON, STATE_OFF) import homeassistant.util.dt as dt_util from homeassistant.components import logbook, recorder from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME @@ -89,7 +93,9 @@ def event_listener(event): calls.append(event) self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener) - self.hass.services.call(logbook.DOMAIN, 'log', {}, True) + + with pytest.raises(vol.Invalid): + self.hass.services.call(logbook.DOMAIN, 'log', {}, True) # Logbook entry service call results in firing an event. # Our service call will unblock when the event listeners have been @@ -586,23 +592,21 @@ def create_state_changed_event(self, event_time_fired, entity_id, state, }, time_fired=event_time_fired) -async def test_logbook_view(hass, aiohttp_client): +async def test_logbook_view(hass, hass_client): """Test the logbook view.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() response = await client.get( '/api/logbook/{}'.format(dt_util.utcnow().isoformat())) assert response.status == 200 -async def test_logbook_view_period_entity(hass, aiohttp_client): +async def test_logbook_view_period_entity(hass, hass_client): """Test the logbook view with period and entity.""" await hass.async_add_job(init_recorder_component, hass) await async_setup_component(hass, 'logbook', {}) - await hass.components.recorder.wait_connection_ready() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) entity_id_test = 'switch.test' @@ -614,7 +618,7 @@ async def test_logbook_view_period_entity(hass, aiohttp_client): await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) - client = await aiohttp_client(hass.http.app) + client = await hass_client() # Today time 00:00:00 start = dt_util.utcnow().date() @@ -748,7 +752,55 @@ async def test_humanify_homekit_changed_event(hass): assert event1['entity_id'] == 'lock.front_door' assert event2['name'] == 'HomeKit' - assert event1['domain'] == DOMAIN_HOMEKIT + assert event2['domain'] == DOMAIN_HOMEKIT assert event2['message'] == \ 'send command set_cover_position to 75 for Window' assert event2['entity_id'] == 'cover.window' + + +async def test_humanify_automation_triggered_event(hass): + """Test humanifying Automation Trigger event.""" + event1, event2 = list(logbook.humanify(hass, [ + ha.Event(EVENT_AUTOMATION_TRIGGERED, { + ATTR_ENTITY_ID: 'automation.hello', + ATTR_NAME: 'Hello Automation', + }), + ha.Event(EVENT_AUTOMATION_TRIGGERED, { + ATTR_ENTITY_ID: 'automation.bye', + ATTR_NAME: 'Bye Automation', + }), + ])) + + assert event1['name'] == 'Hello Automation' + assert event1['domain'] == 'automation' + assert event1['message'] == 'has been triggered' + assert event1['entity_id'] == 'automation.hello' + + assert event2['name'] == 'Bye Automation' + assert event2['domain'] == 'automation' + assert event2['message'] == 'has been triggered' + assert event2['entity_id'] == 'automation.bye' + + +async def test_humanify_script_started_event(hass): + """Test humanifying Script Run event.""" + event1, event2 = list(logbook.humanify(hass, [ + ha.Event(EVENT_SCRIPT_STARTED, { + ATTR_ENTITY_ID: 'script.hello', + ATTR_NAME: 'Hello Script' + }), + ha.Event(EVENT_SCRIPT_STARTED, { + ATTR_ENTITY_ID: 'script.bye', + ATTR_NAME: 'Bye Script' + }), + ])) + + assert event1['name'] == 'Hello Script' + assert event1['domain'] == 'script' + assert event1['message'] == 'started' + assert event1['entity_id'] == 'script.hello' + + assert event2['name'] == 'Bye Script' + assert event2['domain'] == 'script' + assert event2['message'] == 'started' + assert event2['entity_id'] == 'script.bye' diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index 49744421c726ec..68e7602b22822d 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -7,14 +7,14 @@ @pytest.fixture -def prometheus_client(loop, hass, aiohttp_client): - """Initialize an aiohttp_client with Prometheus component.""" +def prometheus_client(loop, hass, hass_client): + """Initialize an hass_client with Prometheus component.""" assert loop.run_until_complete(async_setup_component( hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}, )) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/test_rss_feed_template.py b/tests/components/test_rss_feed_template.py index 64876dbea44a60..391004598e77f7 100644 --- a/tests/components/test_rss_feed_template.py +++ b/tests/components/test_rss_feed_template.py @@ -8,7 +8,7 @@ @pytest.fixture -def mock_http_client(loop, hass, aiohttp_client): +def mock_http_client(loop, hass, hass_client): """Set up test fixture.""" config = { 'rss_feed_template': { @@ -21,7 +21,7 @@ def mock_http_client(loop, hass, aiohttp_client): loop.run_until_complete(async_setup_component(hass, 'rss_feed_template', config)) - return loop.run_until_complete(aiohttp_client(hass.http.app)) + return loop.run_until_complete(hass_client()) @asyncio.coroutine diff --git a/tests/components/test_script.py b/tests/components/test_script.py index 5b7d0dfb70f1fb..790d5c2e844e7a 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -1,15 +1,16 @@ """The tests for the Script component.""" # pylint: disable=protected-access import unittest -from unittest.mock import patch +from unittest.mock import patch, Mock from homeassistant.components import script from homeassistant.components.script import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_RELOAD, SERVICE_TOGGLE, SERVICE_TURN_OFF) + ATTR_ENTITY_ID, ATTR_NAME, SERVICE_RELOAD, SERVICE_TOGGLE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, EVENT_SCRIPT_STARTED) from homeassistant.core import Context, callback, split_entity_id from homeassistant.loader import bind_hass -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import get_test_home_assistant @@ -254,3 +255,48 @@ def test_reload_service(self): assert self.hass.states.get("script.test2") is not None assert self.hass.services.has_service(script.DOMAIN, 'test2') + + +async def test_shared_context(hass): + """Test that the shared context is passed down the chain.""" + event = 'test_event' + context = Context() + + event_mock = Mock() + run_mock = Mock() + + hass.bus.async_listen(event, event_mock) + hass.bus.async_listen(EVENT_SCRIPT_STARTED, run_mock) + + assert await async_setup_component(hass, 'script', { + 'script': { + 'test': { + 'sequence': [ + {'event': event} + ] + } + } + }) + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + context=context) + await hass.async_block_till_done() + + assert event_mock.call_count == 1 + assert run_mock.call_count == 1 + + args, kwargs = run_mock.call_args + assert args[0].context == context + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) == 'test' + assert args[0].data.get(ATTR_ENTITY_ID) == 'script.test' + + # Ensure context carries through the event + args, kwargs = event_mock.call_args + assert args[0].context == context + + # Ensure the script state shares the same context + state = hass.states.get('script.test') + assert state is not None + assert state.context == context diff --git a/tests/components/test_shopping_list.py b/tests/components/test_shopping_list.py index 1e89287bcc106a..f4095b773167bc 100644 --- a/tests/components/test_shopping_list.py +++ b/tests/components/test_shopping_list.py @@ -275,7 +275,7 @@ async def test_ws_update_item_fail(hass, hass_ws_client): @asyncio.coroutine -def test_api_clear_completed(hass, hass_client): +def test_deprecated_api_clear_completed(hass, hass_client): """Test the API.""" yield from async_setup_component(hass, 'shopping_list', {}) @@ -311,6 +311,41 @@ def test_api_clear_completed(hass, hass_client): } +async def test_ws_clear_items(hass, hass_ws_client): + """Test clearing 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'}} + ) + beer_id = hass.data['shopping_list'].items[0]['id'] + wine_id = hass.data['shopping_list'].items[1]['id'] + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'shopping_list/items/update', + 'item_id': beer_id, + 'complete': True + }) + msg = await client.receive_json() + assert msg['success'] is True + await client.send_json({ + 'id': 6, + 'type': 'shopping_list/items/clear' + }) + msg = await client.receive_json() + assert msg['success'] is True + items = hass.data['shopping_list'].items + assert len(items) == 1 + assert items[0] == { + 'id': wine_id, + 'name': 'wine', + 'complete': False + } + + @asyncio.coroutine def test_deprecated_api_create(hass, hass_client): """Test the API.""" diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index bc044999bddc19..977cd966981582 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -2,6 +2,9 @@ import json import logging +import pytest +import voluptuous as vol + from homeassistant.bootstrap import async_setup_component from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips @@ -452,12 +455,11 @@ async def test_snips_say_invalid_config(hass, caplog): snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello', 'badKey': 'boo'} - await hass.services.async_call('snips', 'say', data) + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'say', data) await hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text async def test_snips_say_action_invalid(hass, caplog): @@ -466,12 +468,12 @@ async def test_snips_say_action_invalid(hass, caplog): snips.SERVICE_SCHEMA_SAY_ACTION) data = {'text': 'Hello', 'can_be_enqueued': 'notabool'} - await hass.services.async_call('snips', 'say_action', data) + + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'say_action', data) await hass.async_block_till_done() assert len(calls) == 0 - assert 'ERROR' in caplog.text - assert 'Invalid service data' in caplog.text async def test_snips_feedback_on(hass, caplog): @@ -510,7 +512,8 @@ async def test_snips_feedback_config(hass, caplog): snips.SERVICE_SCHEMA_FEEDBACK) data = {'site_id': 'remote', 'test': 'test'} - await hass.services.async_call('snips', 'feedback_on', data) + with pytest.raises(vol.Invalid): + await hass.services.async_call('snips', 'feedback_on', data) await hass.async_block_till_done() assert len(calls) == 0 diff --git a/tests/components/test_wake_on_lan.py b/tests/components/test_wake_on_lan.py index abaf7dd6d14eb2..cb9f05ba47ba13 100644 --- a/tests/components/test_wake_on_lan.py +++ b/tests/components/test_wake_on_lan.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +import voluptuous as vol from homeassistant.setup import async_setup_component from homeassistant.components.wake_on_lan import ( @@ -34,10 +35,10 @@ def test_send_magic_packet(hass, caplog, mock_wakeonlan): assert mock_wakeonlan.mock_calls[-1][1][0] == mac assert mock_wakeonlan.mock_calls[-1][2]['ip_address'] == bc_ip - yield from hass.services.async_call( - DOMAIN, SERVICE_SEND_MAGIC_PACKET, - {"broadcast_address": bc_ip}, blocking=True) - assert 'ERROR' in caplog.text + with pytest.raises(vol.Invalid): + yield from hass.services.async_call( + DOMAIN, SERVICE_SEND_MAGIC_PACKET, + {"broadcast_address": bc_ip}, blocking=True) assert len(mock_wakeonlan.mock_calls) == 1 yield from hass.services.async_call( diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index afd2b1412dce3e..62a57efb040872 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -1,12 +1,11 @@ """The tests for the timer component.""" # pylint: disable=protected-access import asyncio -import unittest import logging from datetime import timedelta from homeassistant.core import CoreState -from homeassistant.setup import setup_component, async_setup_component +from homeassistant.setup import async_setup_component from homeassistant.components.timer import ( DOMAIN, CONF_DURATION, CONF_NAME, STATUS_ACTIVE, STATUS_IDLE, STATUS_PAUSED, CONF_ICON, ATTR_DURATION, EVENT_TIMER_FINISHED, @@ -15,74 +14,62 @@ from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME, CONF_ENTITY_ID) from homeassistant.util.dt import utcnow -from tests.common import (get_test_home_assistant, async_fire_time_changed) +from tests.common import async_fire_time_changed _LOGGER = logging.getLogger(__name__) -class TestTimer(unittest.TestCase): - """Test the timer 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_config(self): - """Test config.""" - invalid_configs = [ - None, - 1, - {}, - {'name with space': None}, - ] - - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) - - 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()) - - config = { - DOMAIN: { - 'test_1': {}, - 'test_2': { - CONF_NAME: 'Hello World', - CONF_ICON: 'mdi:work', - CONF_DURATION: 10, - } +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] + + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + + +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) + + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) + + config = { + DOMAIN: { + 'test_1': {}, + 'test_2': { + CONF_NAME: 'Hello World', + CONF_ICON: 'mdi:work', + CONF_DURATION: 10, } } + } - assert setup_component(self.hass, 'timer', config) - self.hass.block_till_done() + assert await async_setup_component(hass, 'timer', config) + await hass.async_block_till_done() - assert count_start + 2 == len(self.hass.states.entity_ids()) - self.hass.block_till_done() + assert count_start + 2 == len(hass.states.async_entity_ids()) + await hass.async_block_till_done() - state_1 = self.hass.states.get('timer.test_1') - state_2 = self.hass.states.get('timer.test_2') + state_1 = hass.states.get('timer.test_1') + state_2 = hass.states.get('timer.test_2') - assert state_1 is not None - assert state_2 is not None + assert state_1 is not None + assert state_2 is not None - assert STATUS_IDLE == state_1.state - assert ATTR_ICON not in state_1.attributes - assert ATTR_FRIENDLY_NAME not in state_1.attributes + assert STATUS_IDLE == state_1.state + assert ATTR_ICON not in state_1.attributes + assert ATTR_FRIENDLY_NAME not in state_1.attributes - assert STATUS_IDLE == state_2.state - assert 'Hello World' == \ - state_2.attributes.get(ATTR_FRIENDLY_NAME) - assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) - assert '0:00:10' == state_2.attributes.get(ATTR_DURATION) + assert STATUS_IDLE == state_2.state + assert 'Hello World' == \ + state_2.attributes.get(ATTR_FRIENDLY_NAME) + assert 'mdi:work' == state_2.attributes.get(ATTR_ICON) + assert '0:00:10' == state_2.attributes.get(ATTR_DURATION) @asyncio.coroutine diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 70cbbc15c91bfa..977b0669880c9c 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -2,7 +2,6 @@ import ctypes import os import shutil -import json from unittest.mock import patch, PropertyMock import pytest @@ -14,7 +13,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP) -from homeassistant.setup import setup_component +from homeassistant.setup import setup_component, async_setup_component from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, @@ -584,45 +583,45 @@ def test_setup_component_load_cache_retrieve_without_mem_cache(self): assert req.status_code == 200 assert req.content == demo_data - def test_setup_component_and_web_get_url(self): - """Set up the demo platform and receive wrong file from web.""" - config = { - tts.DOMAIN: { - 'platform': 'demo', - } + +async def test_setup_component_and_web_get_url(hass, hass_client): + """Set up the demo platform and receive file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', } + } - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await async_setup_component(hass, tts.DOMAIN, config) - self.hass.start() + client = await hass_client() - url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) - data = {'platform': 'demo', - 'message': "I person is on front of your door."} + url = "/api/tts_get_url" + data = {'platform': 'demo', + 'message': "I person is on front of your door."} - req = requests.post(url, data=json.dumps(data)) - assert req.status_code == 200 - response = json.loads(req.text) - assert response.get('url') == (("{}/api/tts_proxy/265944c108cbb00b2a62" - "1be5930513e03a0bb2cd_en_-_demo.mp3") - .format(self.hass.config.api.base_url)) + req = await client.post(url, json=data) + assert req.status == 200 + response = await req.json() + assert response.get('url') == \ + ("{}/api/tts_proxy/265944c108cbb00b2a62" + "1be5930513e03a0bb2cd_en_-_demo.mp3".format(hass.config.api.base_url)) - def test_setup_component_and_web_get_url_bad_config(self): - """Set up the demo platform and receive wrong file from web.""" - config = { - tts.DOMAIN: { - 'platform': 'demo', - } + +async def test_setup_component_and_web_get_url_bad_config(hass, hass_client): + """Set up the demo platform and receive wrong file from web.""" + config = { + tts.DOMAIN: { + 'platform': 'demo', } + } - with assert_setup_component(1, tts.DOMAIN): - setup_component(self.hass, tts.DOMAIN, config) + await async_setup_component(hass, tts.DOMAIN, config) - self.hass.start() + client = await hass_client() - url = ("{}/api/tts_get_url").format(self.hass.config.api.base_url) - data = {'message': "I person is on front of your door."} + url = "/api/tts_get_url" + data = {'message': "I person is on front of your door."} - req = requests.post(url, data=data) - assert req.status_code == 400 + req = await client.post(url, json=data) + assert req.status == 400 diff --git a/tests/components/water_heater/test_demo.py b/tests/components/water_heater/test_demo.py index 66116db8cda133..d8c9c71935b05d 100644 --- a/tests/components/water_heater/test_demo.py +++ b/tests/components/water_heater/test_demo.py @@ -1,6 +1,9 @@ """The tests for the demo water_heater component.""" import unittest +import pytest +import voluptuous as vol + from homeassistant.util.unit_system import ( IMPERIAL_SYSTEM ) @@ -48,7 +51,8 @@ def test_set_only_target_temp_bad_attr(self): """Test setting the target temperature without required attribute.""" state = self.hass.states.get(ENTITY_WATER_HEATER) assert 119 == state.attributes.get('temperature') - common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_temperature(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() assert 119 == state.attributes.get('temperature') @@ -69,7 +73,8 @@ def test_set_operation_bad_attr_and_state(self): state = self.hass.states.get(ENTITY_WATER_HEATER) assert "eco" == state.attributes.get('operation_mode') assert "eco" == state.state - common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) + with pytest.raises(vol.Invalid): + common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER) self.hass.block_till_done() state = self.hass.states.get(ENTITY_WATER_HEATER) assert "eco" == state.attributes.get('operation_mode') diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py index b7825600cb1a6c..51d98df7f6061c 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -9,9 +9,10 @@ @pytest.fixture -def websocket_client(hass, hass_ws_client): +def websocket_client(hass, hass_ws_client, hass_access_token): """Create a websocket client.""" - return hass.loop.run_until_complete(hass_ws_client(hass)) + return hass.loop.run_until_complete( + hass_ws_client(hass, hass_access_token)) @pytest.fixture diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index ed54b509aaa3ae..4c0014e478390e 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -13,7 +13,7 @@ from . import API_PASSWORD -async def test_auth_via_msg(no_auth_websocket_client): +async def test_auth_via_msg(no_auth_websocket_client, legacy_auth): """Test authenticating.""" await no_auth_websocket_client.send_json({ 'type': TYPE_AUTH, @@ -70,18 +70,16 @@ async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': hass_access_token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_OK async def test_auth_active_user_inactive(hass, aiohttp_client, @@ -99,18 +97,16 @@ async def test_auth_active_user_inactive(hass, aiohttp_client, client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': hass_access_token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID async def test_auth_active_with_password_not_allow(hass, aiohttp_client): @@ -124,18 +120,16 @@ async def test_auth_active_with_password_not_allow(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active', - return_value=True): - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'api_password': API_PASSWORD - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'api_password': API_PASSWORD + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID async def test_auth_legacy_support_with_password(hass, aiohttp_client): @@ -149,9 +143,7 @@ async def test_auth_legacy_support_with_password(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active', - return_value=True),\ - patch('homeassistant.auth.AuthManager.support_legacy', + with patch('homeassistant.auth.AuthManager.support_legacy', return_value=True): auth_msg = await ws.receive_json() assert auth_msg['type'] == TYPE_AUTH_REQUIRED @@ -176,15 +168,13 @@ async def test_auth_with_invalid_token(hass, aiohttp_client): client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': 'incorrect' - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': 'incorrect' + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_INVALID + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_INVALID diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 84c29533859ff8..78a5bf6d57ea9f 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1,6 +1,4 @@ """Tests for WebSocket API commands.""" -from unittest.mock import patch - from async_timeout import timeout from homeassistant.core import callback @@ -49,6 +47,25 @@ def service_call(call): assert call.data == {'hello': 'world'} +async def test_call_service_not_found(hass, websocket_client): + """Test call service command.""" + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'test_service', + 'service_data': { + 'hello': 'world' + } + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == const.ERR_NOT_FOUND + + async def test_subscribe_unsubscribe_events(hass, websocket_client): """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) @@ -182,18 +199,16 @@ async def test_call_service_context_with_user(hass, aiohttp_client, client = await aiohttp_client(hass.http.app) async with client.ws_connect(URL) as ws: - with patch('homeassistant.auth.AuthManager.active') as auth_active: - auth_active.return_value = True - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'access_token': hass_access_token - }) + await ws.send_json({ + 'type': TYPE_AUTH, + 'access_token': hass_access_token + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK + auth_msg = await ws.receive_json() + assert auth_msg['type'] == TYPE_AUTH_OK await ws.send_json({ 'id': 5, @@ -219,45 +234,56 @@ async def test_call_service_context_with_user(hass, aiohttp_client, assert call.context.user_id == refresh_token.user.id -async def test_call_service_context_no_user(hass, aiohttp_client): - """Test that connection without user sets context.""" - assert await async_setup_component(hass, 'websocket_api', { - 'http': { - 'api_password': API_PASSWORD - } +async def test_subscribe_requires_admin(websocket_client, hass_admin_user): + """Test subscribing events without being admin.""" + hass_admin_user.groups = [] + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'event_type': 'test_event' }) - calls = async_mock_service(hass, 'domain_test', 'test_service') - client = await aiohttp_client(hass.http.app) + msg = await websocket_client.receive_json() + assert not msg['success'] + assert msg['error']['code'] == const.ERR_UNAUTHORIZED - async with client.ws_connect(URL) as ws: - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_REQUIRED - await ws.send_json({ - 'type': TYPE_AUTH, - 'api_password': API_PASSWORD - }) +async def test_states_filters_visible(hass, hass_admin_user, websocket_client): + """Test we only get entities that we're allowed to see.""" + 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') + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_GET_STATES, + }) - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] - await ws.send_json({ - 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, - 'domain': 'domain_test', - 'service': 'test_service', - 'service_data': { - 'hello': 'world' - } - }) + assert len(msg['result']) == 1 + assert msg['result'][0]['entity_id'] == 'test.entity' - msg = await ws.receive_json() - assert msg['success'] - assert len(calls) == 1 - call = calls[0] - assert call.domain == 'domain_test' - assert call.service == 'test_service' - assert call.data == {'hello': 'world'} - assert call.context.user_id is None +async def test_get_states_not_allows_nan(hass, websocket_client): + """Test get_states command not allows NaN floats.""" + hass.states.async_set('greeting.hello', 'world', { + 'hello': float("NaN") + }) + + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_GET_STATES, + }) + + msg = await websocket_client.receive_json() + assert not msg['success'] + assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR diff --git a/tests/components/zha/__init__.py b/tests/components/zha/__init__.py new file mode 100644 index 00000000000000..23d26b50312df4 --- /dev/null +++ b/tests/components/zha/__init__.py @@ -0,0 +1 @@ +"""Tests for the ZHA component.""" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py new file mode 100644 index 00000000000000..e46f1849fa128b --- /dev/null +++ b/tests/components/zha/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for ZHA config flow.""" +from asynctest import patch +from homeassistant.components.zha import config_flow +from homeassistant.components.zha.const import DOMAIN +from tests.common import MockConfigEntry + + +async def test_user_flow(hass): + """Test that config flow works.""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=False): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['errors'] == {'base': 'cannot_connect'} + + with patch('homeassistant.components.zha.config_flow' + '.check_zigpy_connection', return_value=True): + result = await flow.async_step_user( + user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'}) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'ezsp' + } + + +async def test_user_flow_existing_config_entry(hass): + """Test if config entry already exists.""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_user() + + assert result['type'] == 'abort' + + +async def test_import_flow(hass): + """Test import from configuration.yaml .""" + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == '/dev/ttyUSB1' + assert result['data'] == { + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee' + } + + +async def test_import_flow_existing_config_entry(hass): + """Test import from configuration.yaml .""" + MockConfigEntry(domain=DOMAIN, data={ + 'usb_path': '/dev/ttyUSB1' + }).add_to_hass(hass) + flow = config_flow.ZhaFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + 'usb_path': '/dev/ttyUSB1', + 'radio_type': 'xbee', + }) + + assert result['type'] == 'abort' diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index d4077345649d5c..85cca89eefcd96 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -947,7 +947,7 @@ def test_stop_network(self): assert self.zwave_network.stop.called assert len(self.zwave_network.stop.mock_calls) == 1 assert mock_fire.called - assert len(mock_fire.mock_calls) == 2 + assert len(mock_fire.mock_calls) == 1 assert mock_fire.mock_calls[0][1][0] == const.EVENT_NETWORK_STOP def test_rename_node(self): diff --git a/tests/conftest.py b/tests/conftest.py index 84b72189a8d2f7..82ae596fb48e38 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,12 @@ from homeassistant import util from homeassistant.util import location +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from homeassistant.auth.providers import legacy_api_password, homeassistant from tests.common import ( async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro, - mock_storage as mock_storage) + mock_storage as mock_storage, MockUser, CLIENT_ID) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -133,3 +135,67 @@ async def mock_update_config(path, id, entity): side_effect=lambda *args: mock_coro(devices) ): yield devices + + +@pytest.fixture +def hass_access_token(hass, hass_admin_user): + """Return an access token to access Home Assistant.""" + refresh_token = hass.loop.run_until_complete( + hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID)) + yield hass.auth.async_create_access_token(refresh_token) + + +@pytest.fixture +def hass_owner_user(hass, local_auth): + """Return a Home Assistant admin user.""" + return MockUser(is_owner=True).add_to_hass(hass) + + +@pytest.fixture +def hass_admin_user(hass, local_auth): + """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, local_auth): + """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) + + +@pytest.fixture +def legacy_auth(hass): + """Load legacy API password provider.""" + prv = legacy_api_password.LegacyApiPasswordAuthProvider( + hass, hass.auth._store, { + 'type': 'legacy_api_password' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def local_auth(hass): + """Load local auth provider.""" + prv = homeassistant.HassAuthProvider( + hass, hass.auth._store, { + 'type': 'homeassistant' + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + + +@pytest.fixture +def hass_client(hass, aiohttp_client, hass_access_token): + """Return an authenticated HTTP client.""" + async def auth_client(): + """Return an authenticated client.""" + return await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_access_token) + }) + + return auth_client diff --git a/tests/fixtures/awair_air_data_latest.json b/tests/fixtures/awair_air_data_latest.json new file mode 100644 index 00000000000000..674c0662197686 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest.json @@ -0,0 +1,50 @@ +[ + { + "timestamp": "2018-11-21T15:46:16.346Z", + "score": 78, + "sensors": [ + { + "component": "TEMP", + "value": 22.4 + }, + { + "component": "HUMID", + "value": 32.73 + }, + { + "component": "CO2", + "value": 612 + }, + { + "component": "VOC", + "value": 1012 + }, + { + "component": "DUST", + "value": 6.2 + } + ], + "indices": [ + { + "component": "TEMP", + "value": 0 + }, + { + "component": "HUMID", + "value": -2 + }, + { + "component": "CO2", + "value": 0 + }, + { + "component": "VOC", + "value": 2 + }, + { + "component": "DUST", + "value": 0 + } + ] + } +] diff --git a/tests/fixtures/awair_air_data_latest_updated.json b/tests/fixtures/awair_air_data_latest_updated.json new file mode 100644 index 00000000000000..05ad8371232548 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest_updated.json @@ -0,0 +1,50 @@ +[ + { + "timestamp": "2018-11-21T15:46:16.346Z", + "score": 79, + "sensors": [ + { + "component": "TEMP", + "value": 23.4 + }, + { + "component": "HUMID", + "value": 33.73 + }, + { + "component": "CO2", + "value": 613 + }, + { + "component": "VOC", + "value": 1013 + }, + { + "component": "DUST", + "value": 7.2 + } + ], + "indices": [ + { + "component": "TEMP", + "value": 0 + }, + { + "component": "HUMID", + "value": -2 + }, + { + "component": "CO2", + "value": 0 + }, + { + "component": "VOC", + "value": 2 + }, + { + "component": "DUST", + "value": 0 + } + ] + } +] diff --git a/tests/fixtures/awair_devices.json b/tests/fixtures/awair_devices.json new file mode 100644 index 00000000000000..899ad4eed72ba2 --- /dev/null +++ b/tests/fixtures/awair_devices.json @@ -0,0 +1,25 @@ +[ + { + "uuid": "awair_12345", + "deviceType": "awair", + "deviceId": "12345", + "name": "Awair", + "preference": "GENERAL", + "macAddress": "FFFFFFFFFFFF", + "room": { + "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "name": "My Room", + "kind": "LIVING_ROOM", + "Space": { + "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", + "kind": "HOME", + "location": { + "name": "Chicago, IL", + "timezone": "", + "lat": 0, + "lon": -0 + } + } + } + } +] diff --git a/tests/fixtures/entur_public_transport.json b/tests/fixtures/entur_public_transport.json new file mode 100644 index 00000000000000..24eafe94b23ee1 --- /dev/null +++ b/tests/fixtures/entur_public_transport.json @@ -0,0 +1,111 @@ +{ + "data": { + "stopPlaces": [ + { + "id": "NSR:StopPlace:548", + "name": "Bergen stasjon", + "estimatedCalls": [ + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:28:00+0200", + "aimedDepartureTime": "2018-10-10T09:28:00+0200", + "expectedArrivalTime": "2018-10-10T09:28:00+0200", + "expectedDepartureTime": "2018-10-10T09:28:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Bergen" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "NSB:Line:45", + "name": "Vossabanen", + "transportMode": "rail", + "publicCode": "59" + } + } + } + }, + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:35:00+0200", + "aimedDepartureTime": "2018-10-10T09:35:00+0200", + "expectedArrivalTime": "2018-10-10T09:35:00+0200", + "expectedDepartureTime": "2018-10-10T09:35:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Arna" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "NSB:Line:45", + "name": "Vossabanen", + "transportMode": "rail", + "publicCode": "58" + } + } + } + } + ] + } + ], + "quays": [ + { + "id": "NSR:Quay:48550", + "name": "Fiskepiren", + "publicCode": "2", + "latitude": 59.960904, + "longitude": 10.882942, + "estimatedCalls": [ + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:00:00+0200", + "aimedDepartureTime": "2018-10-10T09:00:00+0200", + "expectedArrivalTime": "2018-10-10T09:00:00+0200", + "expectedDepartureTime": "2018-10-10T09:00:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Stavanger Airport via Forum" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "KOL:Line:2900_234", + "name": "Flybussen", + "transportMode": "bus", + "publicCode": "5" + } + } + } + }, + { + "realtime": false, + "aimedArrivalTime": "2018-10-10T09:06:00+0200", + "aimedDepartureTime": "2018-10-10T09:06:00+0200", + "expectedArrivalTime": "2018-10-10T09:06:00+0200", + "expectedDepartureTime": "2018-10-10T09:06:00+0200", + "requestStop": false, + "notices": [], + "destinationDisplay": { + "frontText": "Stavanger" + }, + "serviceJourney": { + "journeyPattern": { + "line": { + "id": "KOL:Line:1000_234", + "name": "1", + "transportMode": "bus", + "publicCode": "1" + } + } + } + } + ] + } + ] + } +} \ No newline at end of file diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 699342381f9539..5cd77eee707361 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -14,7 +14,7 @@ @pytest.fixture -def camera_client(hass, aiohttp_client): +def camera_client(hass, hass_client): """Fixture to fetch camera streams.""" assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', { 'camera': { @@ -23,7 +23,7 @@ def camera_client(hass, aiohttp_client): 'mjpeg_url': 'http://example.com/mjpeg_stream', }})) - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_client()) class TestHelpersAiohttpClient(unittest.TestCase): diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 15dda24a529cc1..b13bc87421b4e0 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,190 +1,220 @@ """The tests for the Restore component.""" -import asyncio -from datetime import timedelta -from unittest.mock import patch, MagicMock +from datetime import datetime -from homeassistant.setup import setup_component from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import CoreState, split_entity_id, State -import homeassistant.util.dt as dt_util -from homeassistant.components import input_boolean, recorder +from homeassistant.core import CoreState, State +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import Entity from homeassistant.helpers.restore_state import ( - async_get_last_state, DATA_RESTORE_CACHE) -from homeassistant.components.recorder.models import RecorderRuns, States + RestoreStateData, RestoreEntity, StoredState, DATA_RESTORE_STATE_TASK) +from homeassistant.util import dt as dt_util -from tests.common import ( - get_test_home_assistant, mock_coro, init_recorder_component, - mock_component) +from asynctest import patch +from tests.common import mock_coro -@asyncio.coroutine -def test_caching_data(hass): - """Test that we cache data.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - states = [ - State('input_boolean.b0', 'on'), - State('input_boolean.b1', 'on'), - State('input_boolean.b2', 'on'), +async def test_caching_data(hass): + """Test that we cache data.""" + now = dt_util.utcnow() + stored_states = [ + StoredState(State('input_boolean.b0', 'on'), now), + StoredState(State('input_boolean.b1', 'on'), now), + StoredState(State('input_boolean.b2', 'on'), now), ] - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') + data = await RestoreStateData.async_get_instance(hass) + await data.store.async_save([state.as_dict() for state in stored_states]) + + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' - assert DATA_RESTORE_CACHE in hass.data - assert hass.data[DATA_RESTORE_CACHE] == {st.entity_id: st for st in states} + # Mock that only b1 is present this run + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data: + state = await entity.async_get_last_state() assert state is not None assert state.entity_id == 'input_boolean.b1' assert state.state == 'on' - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + assert mock_write_data.called - yield from hass.async_block_till_done() - assert DATA_RESTORE_CACHE not in hass.data +async def test_hass_starting(hass): + """Test that we cache data.""" + hass.state = CoreState.starting + now = dt_util.utcnow() + stored_states = [ + StoredState(State('input_boolean.b0', 'on'), now), + StoredState(State('input_boolean.b1', 'on'), now), + StoredState(State('input_boolean.b2', 'on'), now), + ] + + data = await RestoreStateData.async_get_instance(hass) + await data.store.async_save([state.as_dict() for state in stored_states]) + + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None -@asyncio.coroutine -def test_hass_running(hass): - """Test that cache cannot be accessed while hass is running.""" - mock_component(hass, 'recorder') + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + # Mock that only b1 is present this run states = [ - State('input_boolean.b0', 'on'), State('input_boolean.b1', 'on'), - State('input_boolean.b2', 'on'), ] + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + state = await entity.async_get_last_state() - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None + assert state is not None + assert state.entity_id == 'input_boolean.b1' + assert state.state == 'on' + # Assert that no data was written yet, since hass is still starting. + assert not mock_write_data.called -@asyncio.coroutine -def test_not_connected(hass): - """Test that cache cannot be accessed if db connection times out.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + # Finish hass startup + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - states = [State('input_boolean.b1', 'on')] + # Assert that this session states were written + assert mock_write_data.called - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(False)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None +async def test_dump_data(hass): + """Test that we cache data.""" + states = [ + State('input_boolean.b0', 'on'), + State('input_boolean.b1', 'on'), + State('input_boolean.b2', 'on'), + ] -@asyncio.coroutine -def test_no_last_run_found(hass): - """Test that cache cannot be accessed if no last run found.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + entity = Entity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + await entity.async_added_to_hass() + + data = await RestoreStateData.async_get_instance(hass) + now = dt_util.utcnow() + data.last_states = { + 'input_boolean.b0': StoredState(State('input_boolean.b0', 'off'), now), + 'input_boolean.b1': StoredState(State('input_boolean.b1', 'off'), now), + 'input_boolean.b2': StoredState(State('input_boolean.b2', 'off'), now), + 'input_boolean.b3': StoredState(State('input_boolean.b3', 'off'), now), + 'input_boolean.b4': StoredState( + State('input_boolean.b4', 'off'), + datetime(1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)), + } + + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() + + assert mock_write_data.called + args = mock_write_data.mock_calls[0][1] + written_states = args[0] + + # b0 should not be written, since it didn't extend RestoreEntity + # b1 should be written, since it is present in the current run + # b2 should not be written, since it is not registered with the helper + # b3 should be written, since it is still not expired + # b4 should not be written, since it is now expired + assert len(written_states) == 2 + assert written_states[0]['state']['entity_id'] == 'input_boolean.b1' + assert written_states[0]['state']['state'] == 'on' + assert written_states[1]['state']['entity_id'] == 'input_boolean.b3' + assert written_states[1]['state']['state'] == 'off' + + # Test that removed entities are not persisted + await entity.async_will_remove_from_hass() + + with patch('homeassistant.helpers.restore_state.Store.async_save' + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() + + assert mock_write_data.called + args = mock_write_data.mock_calls[0][1] + written_states = args[0] + assert len(written_states) == 1 + assert written_states[0]['state']['entity_id'] == 'input_boolean.b3' + assert written_states[0]['state']['state'] == 'off' + + +async def test_dump_error(hass): + """Test that we cache data.""" + states = [ + State('input_boolean.b0', 'on'), + State('input_boolean.b1', 'on'), + State('input_boolean.b2', 'on'), + ] - states = [State('input_boolean.b1', 'on')] + entity = Entity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=None), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=mock_coro(True)): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + await entity.async_added_to_hass() + data = await RestoreStateData.async_get_instance(hass) -@asyncio.coroutine -def test_cache_timeout(hass): - """Test that cache timeout returns none.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting + with patch('homeassistant.helpers.restore_state.Store.async_save', + return_value=mock_coro(exception=HomeAssistantError) + ) as mock_write_data, patch.object( + hass.states, 'async_all', return_value=states): + await data.async_dump_states() - states = [State('input_boolean.b1', 'on')] + assert mock_write_data.called - @asyncio.coroutine - def timeout_coro(): - raise asyncio.TimeoutError() - with patch('homeassistant.helpers.restore_state.last_recorder_run', - return_value=MagicMock(end=dt_util.utcnow())), \ - patch('homeassistant.helpers.restore_state.get_states', - return_value=states), \ - patch('homeassistant.helpers.restore_state.wait_connection_ready', - return_value=timeout_coro()): - state = yield from async_get_last_state(hass, 'input_boolean.b1') - assert state is None +async def test_load_error(hass): + """Test that we cache data.""" + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + with patch('homeassistant.helpers.storage.Store.async_load', + return_value=mock_coro(exception=HomeAssistantError)): + state = await entity.async_get_last_state() -def _add_data_in_last_run(hass, entities): - """Add test data in the last recorder_run.""" - # pylint: disable=protected-access - t_now = dt_util.utcnow() - timedelta(minutes=10) - t_min_1 = t_now - timedelta(minutes=20) - t_min_2 = t_now - timedelta(minutes=30) - - with recorder.session_scope(hass=hass) as session: - session.add(RecorderRuns( - start=t_min_2, - end=t_now, - created=t_min_2 - )) - - for entity_id, state in entities.items(): - session.add(States( - entity_id=entity_id, - domain=split_entity_id(entity_id)[0], - state=state, - attributes='{}', - last_changed=t_min_1, - last_updated=t_min_1, - created=t_min_1)) - - -def test_filling_the_cache(): - """Test filling the cache from the DB.""" - test_entity_id1 = 'input_boolean.b1' - test_entity_id2 = 'input_boolean.b2' - - hass = get_test_home_assistant() - hass.state = CoreState.starting + assert state is None - init_recorder_component(hass) - _add_data_in_last_run(hass, { - test_entity_id1: 'on', - test_entity_id2: 'off', - }) +async def test_state_saved_on_remove(hass): + """Test that we save entity state on removal.""" + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b0' + await entity.async_added_to_hass() - hass.block_till_done() - setup_component(hass, input_boolean.DOMAIN, { - input_boolean.DOMAIN: { - 'b1': None, - 'b2': None, - }}) + hass.states.async_set('input_boolean.b0', 'on') - hass.start() + data = await RestoreStateData.async_get_instance(hass) - state = hass.states.get('input_boolean.b1') - assert state - assert state.state == 'on' + # No last states should currently be saved + assert not data.last_states - state = hass.states.get('input_boolean.b2') - assert state - assert state.state == 'off' + await entity.async_will_remove_from_hass() - hass.stop() + # We should store the input boolean state when it is removed + assert data.last_states['input_boolean.b0'].state.state == 'on' diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index e5e62d2aed37ef..887a147c41750b 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4,6 +4,10 @@ from unittest import mock import unittest +import voluptuous as vol +import pytest + +from homeassistant import exceptions from homeassistant.core import Context, callback # Otherwise can't test just this file (import order issue) import homeassistant.components # noqa @@ -774,3 +778,84 @@ def test_last_triggered(self): self.hass.block_till_done() assert script_obj.last_triggered == time + + +async def test_propagate_error_service_not_found(hass): + """Test that a script aborts when a service is not found.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script'}, + {'event': 'test_event'}])) + + with pytest.raises(exceptions.ServiceNotFound): + await script_obj.async_run() + + assert len(events) == 0 + + +async def test_propagate_error_invalid_service_data(hass): + """Test that a script aborts when we send invalid service data.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + calls = [] + + @callback + def record_call(service): + """Add recorded event to set.""" + calls.append(service) + + hass.services.async_register('test', 'script', record_call, + schema=vol.Schema({'text': str})) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script', 'data': {'text': 1}}, + {'event': 'test_event'}])) + + with pytest.raises(vol.Invalid): + await script_obj.async_run() + + assert len(events) == 0 + assert len(calls) == 0 + + +async def test_propagate_error_service_exception(hass): + """Test that a script aborts when a service throws an exception.""" + events = [] + + @callback + def record_event(event): + events.append(event) + + hass.bus.async_listen('test_event', record_event) + + calls = [] + + @callback + def record_call(service): + """Add recorded event to set.""" + raise ValueError("BROKEN") + + hass.services.async_register('test', 'script', record_call) + + script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([ + {'service': 'test.script'}, + {'event': 'test_event'}])) + + with pytest.raises(ValueError): + await script_obj.async_run() + + assert len(events) == 0 + assert len(calls) == 0 diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index a4e9a5719434fa..8fca7df69c1346 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -232,7 +232,7 @@ async def test_call_context_target_all(hass, mock_service_platform_call, 'light.kitchen': True } } - })))): + }, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', @@ -253,7 +253,7 @@ async def test_call_context_target_specific(hass, mock_service_platform_call, 'light.kitchen': True } } - })))): + }, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', { @@ -271,7 +271,7 @@ async def test_call_context_target_specific_no_auth( with pytest.raises(exceptions.Unauthorized) as err: with patch('homeassistant.auth.AuthManager.async_get_user', return_value=mock_coro(Mock( - permissions=PolicyPermissions({})))): + permissions=PolicyPermissions({}, None)))): await service.entity_service_call(hass, [ Mock(entities=mock_entities) ], Mock(), ha.ServiceCall('test_domain', 'test_service', { diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 38b8a7cd38039e..7c713082372fd1 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -1,7 +1,8 @@ """Tests for the storage helper.""" import asyncio from datetime import timedelta -from unittest.mock import patch +import json +from unittest.mock import patch, Mock import pytest @@ -31,6 +32,21 @@ async def test_loading(hass, store): assert data == MOCK_DATA +async def test_custom_encoder(hass): + """Test we can save and load data.""" + class JSONEncoder(json.JSONEncoder): + """Mock JSON encoder.""" + + def default(self, o): + """Mock JSON encode method.""" + return "9" + + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder) + await store.async_save(Mock()) + data = await store.async_load() + assert data == "9" + + async def test_loading_non_existing(hass, store): """Test we can save and load data.""" with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 573a9f78b72f72..02331c400d3677 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -274,6 +274,37 @@ def test_max(self): template.Template('{{ [1, 2, 3] | max }}', self.hass).render() + def test_base64_encode(self): + """Test the base64_encode filter.""" + self.assertEqual( + 'aG9tZWFzc2lzdGFudA==', + template.Template('{{ "homeassistant" | base64_encode }}', + self.hass).render()) + + def test_base64_decode(self): + """Test the base64_decode filter.""" + self.assertEqual( + 'homeassistant', + template.Template('{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}', + self.hass).render()) + + def test_ordinal(self): + """Test the ordinal filter.""" + tests = [ + (1, '1st'), + (2, '2nd'), + (3, '3rd'), + (4, '4th'), + (5, '5th'), + ] + + for value, expected in tests: + self.assertEqual( + expected, + template.Template( + '{{ %s | ordinal }}' % value, + self.hass).render()) + def test_timestamp_utc(self): """Test the timestamps to local filter.""" now = dt_util.utcnow() diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 28438a5e4b3625..217f26e71f71b3 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -79,45 +79,6 @@ def test_config_platform_valid(self, isfile_patch): assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - @patch('os.path.isfile', return_value=True) - def test_config_component_platform_fail_validation(self, isfile_patch): - """Test errors if component & platform not found.""" - files = { - YAML_CONFIG_FILE: BASE_CONFIG + 'http:\n password: err123', - } - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res['components'].keys() == {'homeassistant'} - assert res['except'].keys() == {'http'} - assert res['except']['http'][1] == {'http': {'password': 'err123'}} - assert res['secret_cache'] == {} - assert res['secrets'] == {} - assert len(res['yaml_files']) == 1 - - files = { - YAML_CONFIG_FILE: (BASE_CONFIG + 'mqtt:\n\n' - 'light:\n platform: mqtt_json'), - } - with patch_yaml_files(files): - res = check_config.check(get_test_config_dir()) - assert res['components'].keys() == { - 'homeassistant', 'light', 'mqtt'} - assert res['components']['light'] == [] - assert res['components']['mqtt'] == { - 'keepalive': 60, - 'port': 1883, - 'protocol': '3.1.1', - 'discovery': False, - 'discovery_prefix': 'homeassistant', - 'tls_version': 'auto', - } - assert res['except'].keys() == {'light.mqtt_json'} - assert res['except']['light.mqtt_json'][1] == { - 'platform': 'mqtt_json'} - assert res['secret_cache'] == {} - assert res['secrets'] == {} - assert len(res['yaml_files']) == 1 - @patch('os.path.isfile', return_value=True) def test_component_platform_not_found(self, isfile_patch): """Test errors if component or platform not found.""" diff --git a/tests/test_config.py b/tests/test_config.py index 056bf30efe58ff..212fc247eb9a8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,8 +6,10 @@ import unittest.mock as mock from collections import OrderedDict +import asynctest import pytest from voluptuous import MultipleInvalid, Invalid +import yaml from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util @@ -31,7 +33,8 @@ CONFIG_PATH as CUSTOMIZE_CONFIG_PATH) import homeassistant.scripts.check_config as check_config -from tests.common import get_test_config_dir, get_test_home_assistant +from tests.common import ( + get_test_config_dir, get_test_home_assistant, patch_yaml_files) CONFIG_DIR = get_test_config_dir() YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE) @@ -550,6 +553,30 @@ def test_check_ha_config_file_wrong(self, mock_check): ).result() == 'bad' +@asynctest.mock.patch('homeassistant.config.os.path.isfile', + mock.Mock(return_value=True)) +async def test_async_hass_config_yaml_merge(merge_log_err, hass): + """Test merge during async config reload.""" + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: { + 'pack_dict': { + 'input_boolean': {'ib1': None}}}}, + 'input_boolean': {'ib2': None}, + 'light': {'platform': 'test'} + } + + files = {config_util.YAML_CONFIG_FILE: yaml.dump(config)} + with patch_yaml_files(files, True): + conf = await config_util.async_hass_config_yaml(hass) + + assert merge_log_err.call_count == 0 + assert conf[config_util.CONF_CORE].get(config_util.CONF_PACKAGES) \ + is not None + assert len(conf) == 3 + assert len(conf['input_boolean']) == 2 + assert len(conf['light']) == 1 + + # pylint: disable=redefined-outer-name @pytest.fixture def merge_log_err(hass): @@ -808,7 +835,6 @@ async def test_auth_provider_config(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' - assert hass.auth.active is True assert len(hass.auth.auth_mfa_modules) == 2 assert hass.auth.auth_mfa_modules[0].id == 'totp' assert hass.auth.auth_mfa_modules[1].id == 'second' @@ -830,7 +856,6 @@ async def test_auth_provider_config_default(hass): assert len(hass.auth.auth_providers) == 1 assert hass.auth.auth_providers[0].type == 'homeassistant' - assert hass.auth.active is True assert len(hass.auth.auth_mfa_modules) == 1 assert hass.auth.auth_mfa_modules[0].id == 'totp' @@ -852,7 +877,6 @@ async def test_auth_provider_config_default_api_password(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' - assert hass.auth.active is True async def test_auth_provider_config_default_trusted_networks(hass): @@ -873,7 +897,6 @@ async def test_auth_provider_config_default_trusted_networks(hass): assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'trusted_networks' - assert hass.auth.active is True async def test_disallowed_auth_provider_config(hass): diff --git a/tests/test_core.py b/tests/test_core.py index 69cde6c1403ba4..5ee9f5cdb057c0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from tempfile import TemporaryDirectory +import voluptuous as vol import pytz import pytest @@ -21,7 +22,7 @@ __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM, ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, - EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_SERVICE_EXECUTED) + EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE) from tests.common import get_test_home_assistant, async_mock_service @@ -673,13 +674,8 @@ def service_handler(call): def test_call_non_existing_with_blocking(self): """Test non-existing with blocking.""" - prior = ha.SERVICE_CALL_LIMIT - try: - ha.SERVICE_CALL_LIMIT = 0.01 - assert not self.services.call('test_domain', 'i_do_not_exist', - blocking=True) - finally: - ha.SERVICE_CALL_LIMIT = prior + with pytest.raises(ha.ServiceNotFound): + self.services.call('test_domain', 'i_do_not_exist', blocking=True) def test_async_service(self): """Test registering and calling an async service.""" @@ -1005,4 +1001,27 @@ async def handle_outer(call): assert len(calls) == 4 assert [call.service for call in calls] == [ 'outer', 'inner', 'inner', 'outer'] - assert len(hass.bus.async_listeners().get(EVENT_SERVICE_EXECUTED, [])) == 0 + + +async def test_service_call_event_contains_original_data(hass): + """Test that service call event contains original data.""" + events = [] + + @ha.callback + def callback(event): + events.append(event) + + hass.bus.async_listen(EVENT_CALL_SERVICE, callback) + + calls = async_mock_service(hass, 'test', 'service', vol.Schema({ + 'number': vol.Coerce(int) + })) + + await hass.services.async_call('test', 'service', { + 'number': '23' + }, blocking=True) + await hass.async_block_till_done() + assert len(events) == 1 + assert events[0].data['service_data']['number'] == '23' + assert len(calls) == 1 + assert calls[0].data['number'] == 23 diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d662f3b195521c..f5bf0b8a4f8ffe 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -177,6 +177,11 @@ def cookies(self): """Return dict of cookies.""" return self._cookies + @property + def url(self): + """Return yarl of URL.""" + return self._url + @property def content(self): """Return content.""" diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py new file mode 100644 index 00000000000000..8f528376cce748 --- /dev/null +++ b/tests/util/test_aiohttp.py @@ -0,0 +1,54 @@ +"""Test aiohttp request helper.""" +from aiohttp import web + +from homeassistant.util import aiohttp + + +async def test_request_json(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'{"hello": 2}') + assert request.status == 200 + assert await request.json() == { + 'hello': 2 + } + + +async def test_request_text(): + """Test a JSON request.""" + request = aiohttp.MockRequest(b'hello', status=201) + assert request.status == 201 + assert await request.text() == 'hello' + + +async def test_request_post_query(): + """Test a JSON request.""" + request = aiohttp.MockRequest( + b'hello=2&post=true', query_string='get=true', method='POST') + assert request.method == 'POST' + assert await request.post() == { + 'hello': '2', + 'post': 'true' + } + assert request.query == { + 'get': 'true' + } + + +def test_serialize_text(): + """Test serializing a text response.""" + response = web.Response(status=201, text='Hello') + assert aiohttp.serialize_response(response) == { + 'status': 201, + 'body': b'Hello', + 'headers': {'Content-Type': 'text/plain; charset=utf-8'}, + } + + +def test_serialize_json(): + """Test serializing a JSON response.""" + response = web.json_response({"how": "what"}) + assert aiohttp.serialize_response(response) == { + 'status': 200, + 'body': b'{"how": "what"}', + 'headers': {'Content-Type': 'application/json; charset=utf-8'}, + } diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 414a9f400aa010..a7df74d9225a83 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -1,14 +1,17 @@ """Test Home Assistant json utility functions.""" +from json import JSONEncoder import os import unittest import sys from tempfile import mkdtemp -from homeassistant.util.json import (SerializationError, - load_json, save_json) +from homeassistant.util.json import ( + SerializationError, load_json, save_json) from homeassistant.exceptions import HomeAssistantError import pytest +from unittest.mock import Mock + # Test data that can be saved as JSON TEST_JSON_A = {"a": 1, "B": "two"} TEST_JSON_B = {"a": "one", "B": 2} @@ -74,3 +77,17 @@ def test_load_bad_data(self): fh.write(TEST_BAD_SERIALIED) with pytest.raises(HomeAssistantError): load_json(fname) + + def test_custom_encoder(self): + """Test serializing with a custom encoder.""" + class MockJSONEncoder(JSONEncoder): + """Mock JSON encoder.""" + + def default(self, o): + """Mock JSON encode method.""" + return "9" + + fname = self._path_for("test6") + save_json(fname, Mock(), encoder=MockJSONEncoder) + data = load_json(fname) + self.assertEqual(data, "9")