diff --git a/.coveragerc b/.coveragerc index 2a6446092e5..8d98a0c23e0 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 dabc3bbd4db..659f434d14b 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 49f01211e5a..3377bb2a6aa 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -78,11 +78,6 @@ class AuthManager: 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 bad1bdcf913..c6078e03f63 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 homeassistant.util import dt as dt_util 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 @@ class AuthStore: 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 @@ class AuthStore: # 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 @@ class AuthStore: 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 @@ class AuthStore: 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 03be4c74d32..3c26f8b4bde 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -4,13 +4,14 @@ Sending HOTP through notify service """ 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 @@ class NotifySetupFlow(SetupFlow): _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 4b192c35898..588d80047be 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 @@ class User: 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 9113f2b03a9..63e76dd2496 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 @@ class AbstractPermissions: 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 74a43246fd1..0073c952648 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 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ })) 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_VALUES_SCHEMA = vol.Any(True, vol.Schema({ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_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 compile_entities(policy: CategoryType) \ 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 compile_entities(policy: CategoryType) \ 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 00000000000..7ad7d5521c5 --- /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 1871861f291..78d13b9679f 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 9ca4232b610..8828782c886 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -226,7 +226,11 @@ class LoginFlow(data_entry_flow.FlowHandler): 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 8710e7c60bc..2c5a76d2c90 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 @@ import voluptuous as vol 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 @@ class Data: if data is None: data = { - 'salt': generate_secret(), 'users': [] } @@ -94,39 +89,11 @@ class Data: 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 0676cec7fad..c764bfe8c21 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 728b5967db1..77267fd7516 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 @@ class BlinkSyncModule(AlarmControlPanel): """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 44d8a068ce2..21eefc238a0 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 DOMAIN as LUPUSEC_DOMAIN from homeassistant.components.lupusec import LupusecDevice from homeassistant.const import (STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED) + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) DEPENDENCIES = ['lupusec'] @@ -50,6 +51,8 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanel): 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 362923a4ce2..0a79d74d686 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 @@ class ManualAlarm(alarm.AlarmControlPanel): 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 ad1c0d1e3b8..2a91ac77a86 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -19,7 +19,7 @@ from homeassistant.const import ( 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttAlarm(MqttAvailability, MqttDiscoveryUpdate, 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 e512d15fcdd..357a8c350bc 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 @@ from homeassistant.const import ( 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 2a61533a2b9..f06b853087f 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 b001bcd0437..961350bfa89 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -9,7 +9,9 @@ import json 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 @@ from homeassistant.const import ( 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 @@ class APIDomainServicesView(HomeAssistantView): "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 b8774d76873..73cabdfbae6 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 1f12abd3d4e..2073f680e00 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -11,7 +11,6 @@ import voluptuous as vol from requests import RequestException import homeassistant.helpers.config_validation as cv -from homeassistant.core import callback from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, 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 @@ class AugustData: self._door_state_by_id = {} self._activities_by_id = {} - @callback - def august_api_stop(event): - """Close the API HTTP session.""" - _LOGGER.debug("Closing August HTTP session") - - try: - self._api.http_session.close() - self._api.http_session = None - except RequestException: - pass - _LOGGER.debug("August HTTP session closed.") - - self._hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, august_api_stop) - _LOGGER.debug("Registered for HASS stop event") - @property def house_ids(self): """Return a list of house_ids.""" diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json index f4318a0eb21..236352a9018 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 508ffac6739..da234c3dd5d 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 2efc23f78f6..223dc91a480 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 f8563071fbc..4a2df399e0a 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -16,12 +16,13 @@ from homeassistant.core import CoreState 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 async_setup(hass, config): 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 @@ class AutomationEntity(ToggleEntity): 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 @@ class AutomationEntity(ToggleEntity): """ 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 46751ce5394..cd558f03684 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 @@ class BlinkBinarySensor(BinarySensorDevice): 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 124ff88a9a3..ae8029e13f8 100644 --- a/homeassistant/components/binary_sensor/fibaro.py +++ b/homeassistant/components/binary_sensor/fibaro.py @@ -16,6 +16,8 @@ DEPENDENCIES = ['fibaro'] _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 20937af6bfc..fb5b4c0bfc2 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 f7bd353f3d1..acbad0d0419 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -45,8 +45,8 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, 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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 @@ class MqttBinarySensor(MqttAvailability, MqttDiscoveryUpdate, @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 90a8b0b5813..29488d08130 100644 --- a/homeassistant/components/binary_sensor/point.py +++ b/homeassistant/components/binary_sensor/point.py @@ -7,10 +7,11 @@ https://home-assistant.io/components/binary_sensor.point/ 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 @@ EVENTS = { 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 1f83bffdcb6..a85a0c889d1 100644 --- a/homeassistant/components/binary_sensor/sense.py +++ b/homeassistant/components/binary_sensor/sense.py @@ -14,46 +14,48 @@ DEPENDENCIES = ['sense'] _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 7af5a730c43..73035a2da0d 100644 --- a/homeassistant/components/binary_sensor/tahoma.py +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -41,6 +41,7 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): self._state = None self._icon = None self._battery = None + self._available = False @property def is_on(self): @@ -71,6 +72,11 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): 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 @@ class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): 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 450a5e580bd..7f60e40c68b 100644 --- a/homeassistant/components/binary_sensor/tellduslive.py +++ b/homeassistant/components/binary_sensor/tellduslive.py @@ -9,8 +9,9 @@ https://home-assistant.io/components/binary_sensor.tellduslive/ """ 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 e70d3098874..e7092ff16d5 100644 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ b/homeassistant/components/binary_sensor/volvooncall.py @@ -6,17 +6,19 @@ https://home-assistant.io/components/binary_sensor.volvooncall/ """ 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 9365ba42cc1..62c57f0288b 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -7,7 +7,11 @@ at https://home-assistant.io/components/binary_sensor.zha/ 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 @@ CLASS_MAPPING = { 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 - - 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) + """Old way of setting up Zigbee Home Automation binary sensors.""" + pass -async def _async_setup_iaszone(hass, config, async_add_entities, - discovery_info): +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] + + +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(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 @@ class BinarySensor(zha.Entity, BinarySensorDevice): """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 @@ class Remote(zha.Entity, BinarySensorDevice): 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 62e73a52cc8..a56885a22a9 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.10.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 510c2ab2563..e9047914456 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 9db7c138182..2819b0e6ec4 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 83d87311646..e5a0d672756 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -10,14 +10,15 @@ import logging 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_FORCE_RESIZE = 'force_resize' 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 _precheck_image(image, opts): + """Perform some pre-checks on the given image.""" + from PIL import Image + import io + + if not opts: + raise ValueError() + try: + img = Image.open(io.BytesIO(image)) + except IOError: + _LOGGER.warning("Failed to open image") + raise ValueError() + imgfmt = str(img.format) + if imgfmt not in ('PNG', 'JPEG'): + _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 - if not opts: + try: + img = _precheck_image(image, opts) + except ValueError: return image quality = opts.quality or DEFAULT_QUALITY new_width = opts.max_width - - try: - img = Image.open(io.BytesIO(image)) - except IOError: - return image - imgfmt = str(img.format) - if imgfmt not in ('PNG', 'JPEG'): - _LOGGER.debug("Image is of unsupported type: %s", imgfmt) - return image - (old_width, old_height) = img.size old_size = len(image) if old_width <= new_width: @@ -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 @@ class ProxyCamera(Camera): "{} - {}".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 @@ class ProxyCamera(Camera): 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 @@ class ProxyCamera(Camera): _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 @@ class ProxyCamera(Camera): if not image: return None except HomeAssistantError: - raise asyncio.CancelledError + raise asyncio.CancelledError() - return await self.hass.async_add_job( - _resize_image, image.content, self._stream_opts) + 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 c9deca1309d..36c4a3109ba 100644 --- a/homeassistant/components/camera/push.py +++ b/homeassistant/components/camera/push.py @@ -5,35 +5,33 @@ For more details about this platform, please refer to the documentation 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class PushCamera(Camera): 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 @@ class PushCamera(Camera): 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 0e65ac77c1f..50e7c3d8fe2 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -10,12 +10,12 @@ import socket 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 @@ CONF_PASSWORD = 'password' 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 4a5c3258893..38c78bfdb3d 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -192,6 +192,11 @@ class DaikinClimate(ClimateDevice): """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 f0631228fd8..fd58e6c01e8 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 @@ import logging 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_DAYOFF = 'DayOff' 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,171 +65,150 @@ EVO_STATE_TO_HA = { 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) -HA_OP_LIST = list(HA_STATE_TO_EVO) +# the Zones' opmode; their state is usually 'inherited' from the TCS +EVO_FOLLOW = 'FollowSchedule' +EVO_TEMPOVER = 'TemporaryOverride' +EVO_PERMOVER = 'PermanentOverride' -# 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' +# 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) -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 = [] + + 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)) + + entities = [controller] + zones + + async_add_entities(entities, update_before_add=False) -class EvoController(ClimateDevice): - """Base for a Honeywell evohome hub/Controller device. +class EvoClimateDevice(ClimateDevice): + """Base for a Honeywell evohome Climate device.""" - The Controller (aka TCS, temperature control system) is the parent of all - the child (CH/DHW) devices. - """ + # pylint: disable=no-member def __init__(self, evo_data, client, obj_ref): - """Initialize the evohome entity. - - Most read-only properties are set here. So are pseudo read-only, - for example name (which _could_ change between update()s). - """ - self.client = client + """Initialize the evohome entity.""" + self._client = client self._obj = obj_ref - self._id = obj_ref.systemId - self._name = evo_data['config']['locationInfo']['name'] - - 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 - - @property - def device_state_attributes(self): - """Return the device state attributes of the controller. - - This is operating mode state data that is not available otherwise, due - to the restrictions placed upon ClimateDevice properties, etc by HA. - """ - 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 + """Get the list of supported features of the device.""" + return self._supported_features @property def operation_list(self): """Return the list of available operations.""" - return HA_OP_LIST - - @property - def current_operation(self): - """Return the operation mode of the evohome entity.""" - return EVO_STATE_TO_HA.get(self._status['systemModeStatus']['mode']) - - @property - def target_temperature(self): - """Return the average target temperature of the Heating/DHW zones.""" - temps = [zone['setpointStatus']['targetHeatTemperature'] - for zone in self._status['zones']] - - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp - - @property - def current_temperature(self): - """Return the average current temperature of the Heating/DHW zones.""" - tmp_list = [x for x in self._status['zones'] - if x['temperatureStatus']['isAvailable'] is True] - temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list] - - avg_temp = round(sum(temps) / len(temps), 1) if temps else None - return avg_temp + return self._operation_list @property def temperature_unit(self): @@ -227,47 +218,313 @@ class EvoController(ClimateDevice): @property def precision(self): """Return the temperature precision to use in the frontend UI.""" - return PRECISION_TENTHS + 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 = {} + + 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 temp (setpoint) of a evohome entity.""" - return MIN_TEMP + """Return the minimum target temperature of a evohome Zone. + + The default is 5 (in Celsius), but it is configurable within 5-35. + """ + return self._config['setpointCapabilities']['minHeatSetpoint'] @property def max_temp(self): - """Return the maximum target temp (setpoint) of a evohome entity.""" - return MAX_TEMP + """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 is_on(self): - """Return true as evohome controllers are always on. + def target_temperature(self): + """Return the target temperature of the evohome Zone.""" + return self._status['setpointStatus']['targetHeatTemperature'] - Operating modes can include 'HeatingOff', but (for example) DHW would - remain on. + @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 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. + + 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']] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def current_temperature(self): + """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] + + avg_temp = round(sum(temps) / len(temps), 1) if temps else None + return avg_temp + + @property + def is_on(self) -> bool: + """Return True as evohome Controllers are always 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 @@ class EvoController(ClimateDevice): 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 212c4265d8a..ffab50c989d 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -23,7 +23,7 @@ from homeassistant.helpers import condition 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 @@ class GenericThermostat(ClimateDevice): 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 c445a495073..e0f104a84b1 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 6be4fe183b7..5ea48614f6b 100644 --- a/homeassistant/components/climate/mill.py +++ b/homeassistant/components/climate/mill.py @@ -19,7 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['millheater==0.2.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 b107710fea5..098ff2867da 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -22,7 +22,8 @@ from homeassistant.const import ( 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_MIN_TEMP = 'min_temp' 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_setup_entry(hass, config_entry, async_add_entities): 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 - 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) + 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 _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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): _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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): _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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): # 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): 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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): """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 @@ class MqttClimate(MqttAvailability, MqttDiscoveryUpdate, ClimateDevice): @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 5972ff52a8b..022a509ce06 100644 --- a/homeassistant/components/climate/toon.py +++ b/homeassistant/components/climate/toon.py @@ -15,6 +15,14 @@ from homeassistant.const import TEMP_CELSIUS 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 @@ class ThermostatDevice(ClimateDevice): @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 @@ class ThermostatDevice(ClimateDevice): 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 ba5621b1f8d..fd5b413043e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -20,7 +20,7 @@ from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot, auth_api, 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_RELAYER = 'relayer' CONF_USER_POOL_ID = 'user_pool_id' CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' DEFAULT_MODE = 'production' DEPENDENCIES = ['http'] @@ -78,6 +79,7 @@ CONFIG_SCHEMA = vol.Schema({ 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 @@ class Cloud: 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 @@ class Cloud: 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 @@ class Cloud: 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 @@ class Cloud: 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 00000000000..c62768cc514 --- /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 00000000000..3c638d29166 --- /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 abc72da796c..a5019efaa8e 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -6,6 +6,7 @@ REQUEST_TIMEOUT = 10 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 @@ SERVERS = { '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 7b509f4eae2..03a77c08d4b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ import asyncio from functools import wraps import logging +import aiohttp import async_timeout import voluptuous as vol @@ -44,6 +45,20 @@ SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +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 @@ _CLOUD_ERRORS = { 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 c5657ae9729..7d633a4b2ac 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 @@ class CloudIoT: 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 @@ class CloudIoT: 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 @@ class CloudIoT: _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 @@ class CloudIoT: _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 7e1ec6a0232..32362df2fa9 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 @@ class CloudPreferences: 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 @@ class CloudPreferences: 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 f2cfff1f342..4154ca337a3 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -14,6 +14,8 @@ from homeassistant.util.yaml import load_yaml, dump DOMAIN = 'config' DEPENDENCIES = ['http'] SECTIONS = ( + 'auth', + 'auth_provider_homeassistant', 'automation', 'config_entries', 'core', @@ -58,10 +60,6 @@ async def async_setup(hass, config): 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 d67c93c0d6e..228870489a2 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 @@ class Counter(Entity): 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 f51cca8a276..55df204f275 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -24,7 +23,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, 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 @@ PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttCover(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 9d292d9e8b5..67affdae04e 100644 --- a/homeassistant/components/cover/tellduslive.py +++ b/homeassistant/components/cover/tellduslive.py @@ -8,8 +8,9 @@ https://home-assistant.io/components/cover.tellduslive/ """ 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 @@ class TelldusLiveCover(TelldusLiveEntity, CoverDevice): 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 4fcd33bee26..e2e4572939d 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -132,3 +132,8 @@ class DaikinApi: _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 10eb9f5bc73..a3aa5491e23 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 3de7de9ddb3..51cb5419e90 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 a9b66314f31..3ff60254a6a 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 ad792d035cc..202883713c7 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -22,9 +22,8 @@ from homeassistant.components.zone.zone import async_active_zone 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 @@ class DeviceTracker: for device in self.devices.values(): if (device.track and device.last_update_home) and \ device.stale(now): - device.mark_stale() self.hass.async_create_task(device.async_update_ha_state(True)) async def async_setup_tracked_device(self): @@ -407,7 +405,7 @@ class DeviceTracker: 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 @@ class Device(Entity): 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 e7d60aaed6d..821182ec103 100644 --- a/homeassistant/components/device_tracker/bt_smarthub.py +++ b/homeassistant/components/device_tracker/bt_smarthub.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import ( 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 1995179ff5a..1f95414541c 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -19,7 +19,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify, dt as dt_util -REQUIREMENTS = ['locationsharinglib==3.0.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 5b69c13afa6..cddcd1f26ee 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -16,7 +16,7 @@ from homeassistant.components.device_tracker import ( 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 @@ class MikrotikScanner(DeviceScanner): 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 @@ class MikrotikScanner(DeviceScanner): 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 a9afc76e67c..40a6f48d889 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ from homeassistant.components.device_tracker import ( 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 7872f8f1f1c..395b539a065 100644 --- a/homeassistant/components/device_tracker/volvooncall.py +++ b/homeassistant/components/device_tracker/volvooncall.py @@ -7,33 +7,32 @@ https://home-assistant.io/components/device_tracker.volvooncall/ 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') + await async_see(dev_id=dev_id, + host_name=host_name, + source_type=SOURCE_TYPE_GPS, + gps=instrument.state, + icon='mdi:car') - dispatcher_connect(hass, SIGNAL_VEHICLE_SEEN, see_vehicle) - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + 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 1abd86ffd8a..c5c6ebcbc35 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker import ( 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 aa81c06d750..ffc10269776 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 00000000000..89e8205bb09 --- /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 f9a71747bd6..cf53f81bdb8 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 7bc785f2613..8625780e65c 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 96c79053dff..bbf40c73070 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -134,6 +134,7 @@ async def async_setup(hass, config): 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 94248000601..8ac3cec6411 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType # noqa 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 397d3b9f6c0..40ba5b9b70f 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 @@ https://home-assistant.io/components/evohome/ """ # 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 @@ CONFIG_SCHEMA = vol.Schema({ 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. - One controller with 0+ heating zones (e.g. TRVs, relays) and, optionally, a - DHW controller. Does not work for US-based systems. +def setup(hass, hass_config): + """Create a (EMEA/EU-based) Honeywell evohome system. + + 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." + ) - finally: # Redact username, password as no longer needed. + 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 + + 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(), location = %s", tmp_loc) + _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc) - load_platform(hass, 'climate', DOMAIN, {}, config) + load_platform(hass, 'climate', DOMAIN, {}, hass_config) + + @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) + + 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 1ff04cd913a..8f3ec842829 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/fan.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -18,7 +17,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, - 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,40 +106,7 @@ 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), - { - key: config.get(key) for key in ( - CONF_STATE_TOPIC, - CONF_COMMAND_TOPIC, - CONF_SPEED_STATE_TOPIC, - CONF_SPEED_COMMAND_TOPIC, - CONF_OSCILLATION_STATE_TOPIC, - CONF_OSCILLATION_COMMAND_TOPIC, - ) - }, - { - 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), - { - STATE_ON: config.get(CONF_PAYLOAD_ON), - STATE_OFF: config.get(CONF_PAYLOAD_OFF), - OSCILLATE_ON_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_ON), - OSCILLATE_OFF_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_OFF), - 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), + config, discovery_hash, )]) @@ -149,43 +115,95 @@ 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): + def __init__(self, config, 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 - self._optimistic_oscillation = ( - optimistic or topic[CONF_OSCILLATION_STATE_TOPIC] is None) - self._optimistic_speed = ( - optimistic or topic[CONF_SPEED_STATE_TOPIC] is None) + self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False self._speed = None self._oscillation = None self._supported_features = 0 - self._supported_features |= (topic[CONF_OSCILLATION_STATE_TOPIC] - is not None and SUPPORT_OSCILLATE) - self._supported_features |= (topic[CONF_SPEED_STATE_TOPIC] - is not None and SUPPORT_SET_SPEED) - self._unique_id = unique_id - self._discovery_hash = discovery_hash + 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 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 + self._topic = { + key: config.get(key) for key in ( + CONF_STATE_TOPIC, + CONF_COMMAND_TOPIC, + CONF_SPEED_STATE_TOPIC, + CONF_SPEED_COMMAND_TOPIC, + 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) + } + 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), + OSCILLATE_OFF_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_OFF), + SPEED_LOW: config.get(CONF_PAYLOAD_LOW_SPEED), + SPEED_MEDIUM: config.get(CONF_PAYLOAD_MEDIUM_SPEED), + SPEED_HIGH: config.get(CONF_PAYLOAD_HIGH_SPEED), + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._optimistic_oscillation = ( + optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None) + self._optimistic_speed = ( + optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None) + + self._supported_features = 0 + self._supported_features |= (self._topic[CONF_OSCILLATION_STATE_TOPIC] + is not None and SUPPORT_OSCILLATE) + self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC] + is not None and SUPPORT_SET_SPEED) + + 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """ 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, """ 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttFan(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 3462b0bc1eb..e6349782cd1 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -18,7 +18,7 @@ from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, 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 @@ class XiaomiAirHumidifier(XiaomiGenericDevice): 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 b5615f18d73..d1731e89894 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -5,10 +5,15 @@ For more details on this platform, please refer to the documentation 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 @@ SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)} 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 - - async_add_entities([ZhaFan(**discovery_info)], update_before_add=True) + """Old way of setting up Zigbee Home Automation fans.""" + pass -class ZhaFan(zha.Entity, FanEntity): +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(entities, update_before_add=True) + + +class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN @@ -101,9 +129,9 @@ class ZhaFan(zha.Entity, FanEntity): 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 85bd5c3c018..5813b194890 100644 --- a/homeassistant/components/fibaro.py +++ b/homeassistant/components/fibaro.py @@ -7,6 +7,7 @@ https://home-assistant.io/components/fibaro/ 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_POWER_W = "current_power_w" 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 @@ FIBARO_TYPEMAP = { '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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroController(): 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 @@ class FibaroDevice(Entity): 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 @@ class FibaroDevice(Entity): 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 @@ class FibaroDevice(Entity): 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 d8ea057a4f0..8caca591305 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import callback from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass -REQUIREMENTS = ['home-assistant-frontend==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 @@ async def async_setup(hass, config): 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 @@ class IndexView(HomeAssistantView): # 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 @@ class IndexView(HomeAssistantView): 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 74d1b036f6c..4d8c3b68edd 100644 --- a/homeassistant/components/geo_location/geo_json_events.py +++ b/homeassistant/components/geo_location/geo_json_events.py @@ -13,7 +13,8 @@ import voluptuous as vol 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 @@ SOURCE = 'geo_json_events' 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 setup_platform(hass, config, add_entities, discovery_info=None): 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) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - 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) + 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 @@ class GeoJsonLocationEvent(GeoLocationEvent): 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 1d2a7fadaff..5681e4a53ac 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 @@ from homeassistant.components.geo_location import ( 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 @@ VALID_CATEGORIES = [ 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 setup_platform(hass, config, add_entities, discovery_info=None): 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) + self._hass, lambda now: self._feed_manager.update(), + self._scan_interval) - def _update(self): - """Update the feed and then update connected entities.""" - import geojson_client + def get_entry(self, external_id): + """Get feed entry by external id.""" + return self._feed_manager.feed_entries.get(external_id) - 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) + 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 @@ class NswRuralFireServiceLocationEvent(GeoLocationEvent): @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 00000000000..f835fecfeb4 --- /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 1a571960bc7..c16390302d6 100644 --- a/homeassistant/components/goalfeed.py +++ b/homeassistant/components/goalfeed.py @@ -12,8 +12,9 @@ import voluptuous as vol 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 f444974bc8d..bf0c72ec1c8 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -33,8 +33,6 @@ _LOGGER = logging.getLogger(__name__) 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 async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): 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 aca960f9c0a..bfeb0fcadf5 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -19,8 +19,6 @@ DEFAULT_EXPOSED_DOMAINS = [ '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 d688491fe89..f0294c3bcb2 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -48,7 +48,7 @@ def async_register_http(hass, cfg): 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 6019b75bd98..7d3af71ac2b 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 61231a7894d..7153115e3ef 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -43,6 +43,7 @@ TRAIT_SCENE = PREFIX_TRAITS + 'Scene' 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_TEMPERATURE_SET_RANGE = ( 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 @@ class TemperatureSettingTrait(_Trait): 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 @@ class TemperatureSettingTrait(_Trait): 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 @@ class TemperatureSettingTrait(_Trait): "{} 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 @@ class FanSpeedTrait(_Trait): 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 6bfcaaa5d85..3c058281b0a 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 c3bd18fa9bb..be2806716a7 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -15,7 +15,6 @@ from aiohttp import web 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 @@ class HassIOView(HomeAssistantView): 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 b5d64f48dc7..a630a9ef1ad 100644 --- a/homeassistant/components/hdmi_cec.py +++ b/homeassistant/components/hdmi_cec.py @@ -320,38 +320,39 @@ def setup(hass: HomeAssistant, base_config): 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 21d4cdc6e56..1773a55b3f1 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -38,20 +38,6 @@ SIGNIFICANT_DOMAINS = ('thermostat', 'climate') 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 00000000000..cfbb8ac010c --- /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 d5336217221..ee99e236fa9 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,12 +13,13 @@ import voluptuous as vol 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 @@ HM_DEVICE_TYPES = { '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_PORT = 2001 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 @@ DEVICE_SCHEMA = vol.Schema({ 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 @@ CONFIG_SCHEMA = vol.Schema({ 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 7cc5943b830..9ad495c720a 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 ae67c616f3f..e1aec6162f4 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 7180002430a..a6b9588fce3 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -200,14 +200,13 @@ class HomeAssistantHTTP: 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 ae6abf04c02..6cd211613ce 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 @@ def setup_auth(app, trusted_networks, use_auth, 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 30d4ed0ab8d..beb5c647266 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -9,7 +9,9 @@ import json 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 @@ class HomeAssistantView: """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 @@ def request_handler_factory(view, handler): 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 4b2581dde65..b6e2ccce8ed 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 4245ce02c66..05d52d5c37e 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 f93fbe19078..aadd66902b6 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 2f033e4f4ee..bb54f7ef6cb 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 3c1d7b580e4..dc846993e2e 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 9b00f3bd789..052921ad37a 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -9,16 +9,18 @@ import os.path 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 @@ from homeassistant.helpers.typing import HomeAssistantType 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' -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, - }), + +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.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 @@ IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch') 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 setup_service_functions(hass: HomeAssistantType, ihc_controller): hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) - - -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 b06746c8e7a..d6e4d0e0d4d 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -10,6 +10,9 @@ CONF_BINARY_SENSOR = 'binary_sensor' 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 84d92361541..72a4a8155e2 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -76,10 +76,14 @@ async def async_setup(hass, config): """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 8f5b599bb88..6172963525e 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -20,7 +20,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.15.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 6d54324542a..dfb41ddf617 100644 --- a/homeassistant/components/influxdb.py +++ b/homeassistant/components/influxdb.py @@ -23,7 +23,7 @@ from homeassistant.helpers import state as state_helper 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 18c9808c6d2..541e38202fc 100644 --- a/homeassistant/components/input_boolean.py +++ b/homeassistant/components/input_boolean.py @@ -15,7 +15,7 @@ from homeassistant.loader import bind_hass 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 @@ class InputBoolean(ToggleEntity): 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 df35ae53ba9..6ac9a24d044 100644 --- a/homeassistant/components/input_datetime.py +++ b/homeassistant/components/input_datetime.py @@ -11,9 +11,8 @@ import voluptuous as vol 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_setup(hass, config): 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 @@ class InputDatetime(Entity): 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 @@ class InputDatetime(Entity): # 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 f52b9add821..b6c6eab3cf5 100644 --- a/homeassistant/components/input_number.py +++ b/homeassistant/components/input_number.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 @@ class InputNumber(Entity): 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 b8398e1be3d..cc9a73bf915 100644 --- a/homeassistant/components/input_select.py +++ b/homeassistant/components/input_select.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 @@ class InputSelect(Entity): 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 956d9a6466d..8ac64b398f4 100644 --- a/homeassistant/components/input_text.py +++ b/homeassistant/components/input_text.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 @@ class InputText(Entity): 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 95d675076da..a311daa6f9e 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 c78905b09c8..255b2efc91a 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 52df3d47ca1..f2713197ed1 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -8,7 +8,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN DOMAIN = 'lifx' -REQUIREMENTS = ['aiolifx==0.6.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 da7ccfb2db2..b9c575dbd5a 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 7157dcfd31b..636e4376ae2 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 @@ class FibaroLight(FibaroDevice, Light): 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 @@ class FibaroLight(FibaroDevice, Light): # 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 da90a53c848..f80c9b2fd6f 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 00000000000..50c664d9046 --- /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 2e2971cfdc2..3a0225d8d65 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( 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 state(new_state): return decorator -class LimitlessLEDGroup(Light): +class LimitlessLEDGroup(Light, RestoreEntity): """Representation of a LimitessLED group.""" def __init__(self, group, config): @@ -189,7 +189,8 @@ class LimitlessLEDGroup(Light): 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 6c4047e2314..ee08e532ce7 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 @@ class LutronLight(LutronDevice, Light): 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 00000000000..93f32cd2791 --- /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 92030c8617a..74f3dbdec91 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt/schema_basic.py @@ -9,7 +9,7 @@ import logging 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 @@ from homeassistant.const import ( 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 @@ DEFAULT_ON_COMMAND_TYPE = 'last' 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ }).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_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): +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.get(CONF_NAME), - config.get(CONF_UNIQUE_ID), - config.get(CONF_EFFECT_LIST), - { + async_add_entities([MqttLight(config, discovery_hash)]) + + +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 + + 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 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): @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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): @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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttLight(MqttAvailability, MqttDiscoveryUpdate, Light): """ 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 1ed43a6385a..8a72f7b1f89 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt/schema_json.py @@ -6,7 +6,6 @@ https://home-assistant.io/components/light.mqtt_json/ """ import json import logging -from typing import Optional import voluptuous as vol @@ -14,23 +13,23 @@ 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, 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_HS = 'hs' 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }).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) + async_add_entities([MqttLightJson(config, discovery_hash)]) - 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), - { +class MqttLightJson(MqttAvailability, MqttDiscoveryUpdate, Light, + RestoreEntity): + """Representation of a MQTT JSON light.""" + + 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.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 ) - }, - 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, - )]) + } + optimistic = config.get(CONF_OPTIMISTIC) + self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None - -class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): - """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]): - """Initialize MQTT JSON 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._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None - self._state = False - self._rgb = rgb - self._xy = xy - self._hs_support = hs + 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): @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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): @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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 @@ class MqttJson(MqttAvailability, MqttDiscoveryUpdate, Light): 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 72cfd6b678c..419472d1927 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt/schema_template.py @@ -11,17 +11,17 @@ from homeassistant.core import callback 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_RED_TEMPLATE = 'red_template' 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }).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(config, discovery_hash)]) - async_add_entities([MqttTemplate( - hass, - config.get(CONF_NAME), - config.get(CONF_EFFECT_LIST), - { + +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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): @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 @@ class MqttTemplate(MqttAvailability, Light): @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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): 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 @@ class MqttTemplate(MqttAvailability, Light): 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 3146954ed62..6b58ced5989 100644 --- a/homeassistant/components/light/niko_home_control.py +++ b/homeassistant/components/light/niko_home_control.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/light.niko_home_control/ """ import logging -import socket import voluptuous as vol @@ -24,11 +23,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ }) -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 @@ class NikoHomeControlLight(Light): """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 07b5458fa45..8601fe3cf1f 100644 --- a/homeassistant/components/light/tellduslive.py +++ b/homeassistant/components/light/tellduslive.py @@ -8,9 +8,10 @@ https://home-assistant.io/components/light.tellduslive/ """ 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 f2e8e120d53..9e650562fe8 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.exceptions import PlatformNotReady 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 15f2d24fa8a..25704eea0cc 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -76,6 +76,7 @@ EFFECT_CHRISTMAS = "Christmas" 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 @@ YEELIGHT_EFFECT_LIST = [ EFFECT_RGB, EFFECT_RANDOM_LOOP, EFFECT_FAST_RANDOM_LOOP, + EFFECT_LSD, EFFECT_SLOWDOWN, EFFECT_WHATSAPP, EFFECT_FACEBOOK, @@ -413,34 +415,30 @@ class YeelightLight(Light): 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 56a1e9e5169..83448b39d9e 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -5,7 +5,13 @@ For more details on this platform, please refer to the documentation 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 @@ UNSUPPORTED_ATTRIBUTE = 0x86 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) + """Old way of setting up Zigbee Home Automation lights.""" + pass -class Light(zha.Entity, light.Light): +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 @@ class Light(zha.Entity, light.Light): 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 00000000000..e1aa1664eba --- /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 b62382e6dd1..28849c88159 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -111,8 +111,7 @@ class MqttLock(MqttAvailability, MqttDiscoveryUpdate, LockDevice): 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 877c8a1ddf6..25c7e1aa8ea 100644 --- a/homeassistant/components/lock/verisure.py +++ b/homeassistant/components/lock/verisure.py @@ -8,7 +8,8 @@ import logging 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 @@ class VerisureDoorlock(LockDevice): 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 @@ class VerisureDoorlock(LockDevice): """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 58fa83cef30..83301aa3d4e 100644 --- a/homeassistant/components/lock/volvooncall.py +++ b/homeassistant/components/lock/volvooncall.py @@ -7,17 +7,18 @@ https://home-assistant.io/components/lock.volvooncall/ 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 b6f434a82ad..78da5733a06 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -17,7 +17,8 @@ from homeassistant.const import ( 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 5234dbaf29d..e6f122bce19 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -7,466 +7,139 @@ at https://www.home-assistant.io/lovelace/ from functools import wraps import logging import os -from typing import Dict, List, Union import time -import uuid import voluptuous as vol 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' + +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) + LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml' -JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name -FORMAT_YAML = 'yaml' -FORMAT_JSON = 'json' - -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' - -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_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), + vol.Required('type'): WS_TYPE_GET_LOVELACE_UI, + vol.Optional('force', default=False): bool, }) -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), -}) - -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_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, -}) - -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), -}) - -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), -}) - -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), -}) - -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, -}) - -SCHEMA_DELETE_VIEW = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): WS_TYPE_DELETE_VIEW, - vol.Required('view_id'): str, +SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_SAVE_CONFIG, + vol.Required('config'): vol.Any(str, dict), }) -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 ConfigNotFound(HomeAssistantError): + """When no config available.""" 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) + # Pass in default to `get` because defaults not set if loaded as dep + mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) - hass.components.websocket_api.async_register_command( - WS_TYPE_MIGRATE_CONFIG, websocket_lovelace_migrate_config, - SCHEMA_MIGRATE_CONFIG) + await hass.components.frontend.async_register_built_in_panel( + DOMAIN, config={ + 'mode': mode + }) + + if mode == MODE_YAML: + hass.data[DOMAIN] = LovelaceYAML(hass) + else: + hass.data[DOMAIN] = LovelaceStorage(hass) hass.components.websocket_api.async_register_command( WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, SCHEMA_GET_LOVELACE_UI) hass.components.websocket_api.async_register_command( - WS_TYPE_GET_CARD, websocket_lovelace_get_card, SCHEMA_GET_CARD) - - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_CARD, websocket_lovelace_update_card, - SCHEMA_UPDATE_CARD) - - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_CARD, websocket_lovelace_add_card, SCHEMA_ADD_CARD) - - 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) - - hass.components.websocket_api.async_register_command( - WS_TYPE_GET_VIEW, websocket_lovelace_get_view, SCHEMA_GET_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_UPDATE_VIEW, websocket_lovelace_update_view, - SCHEMA_UPDATE_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_ADD_VIEW, websocket_lovelace_add_view, SCHEMA_ADD_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_MOVE_VIEW, websocket_lovelace_move_view, SCHEMA_MOVE_VIEW) - - hass.components.websocket_api.async_register_command( - WS_TYPE_DELETE_VIEW, websocket_lovelace_delete_view, - SCHEMA_DELETE_VIEW) + WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config, + SCHEMA_SAVE_CONFIG) return True +class LovelaceStorage: + """Class to handle Storage based Lovelace config.""" + + def __init__(self, hass): + """Initialize Lovelace config based on storage helper.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None + + 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} + + config = self._data['config'] + + if config is None: + raise ConfigNotFound + + return config + + async def async_save(self, config): + """Save config.""" + self._data['config'] = config + await self._store.async_save(self._data) + + +class LovelaceYAML: + """Class to handle YAML-based Lovelace config.""" + + def __init__(self, hass): + """Initialize the YAML config.""" + self.hass = hass + self._cache = None + + async def async_load(self, force): + """Load config.""" + return await self.hass.async_add_executor_job(self._load_config, force) + + 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 + + try: + config = load_yaml(fname) + except FileNotFoundError: + raise ConfigNotFound from None + + self._cache = (config, time.time()) + return config + + async def async_save(self, config): + """Save config.""" + raise HomeAssistantError('Not supported') + + def handle_yaml_errors(func): """Handle error with WebSocket calls.""" @wraps(func) @@ -477,19 +150,8 @@ def handle_yaml_errors(func): 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 @@ def handle_yaml_errors(func): @handle_yaml_errors async def websocket_lovelace_config(hass, connection, msg): """Send Lovelace UI config over WebSocket configuration.""" - return await hass.async_add_executor_job(load_config, hass) + return await hass.data[DOMAIN].async_load(msg['force']) @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']) - - -@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 00000000000..48914a94465 --- /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 00000000000..6a242c441af --- /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 506a5c05485..d37aa3567d1 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 162b49ef9b2..94cb3abc4a2 100644 --- a/homeassistant/components/lupusec.py +++ b/homeassistant/components/lupusec.py @@ -16,7 +16,7 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, 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 f31c4838a4d..fcb087e6885 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 0dd8cbdb47d..95897a25f15 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 62007a95809..b1828ee28ef 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 12dad4d8c7e..4eb12d7343c 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 de60f7eee93..296c6c8d75d 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.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 c2a736f531e..8a88e3bd74e 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -4,6 +4,7 @@ Demo implementation of the media player. 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 @@ from homeassistant.components.media_player import ( 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 @@ DEFAULT_SOUND_MODE = 'Dummy Music' 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 bf934311303..c565a161b10 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 7a1e240d82e..d8c67e372b2 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -162,8 +162,8 @@ class DirecTvDevice(MediaPlayerDevice): 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 0c1984b3bce..80be58c04e1 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 @@ class FireTVDevice(MediaPlayerDevice): 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 cb4afadd058..d69d8a74ce6 100644 --- a/homeassistant/components/media_player/hdmi_cec.py +++ b/homeassistant/components/media_player/hdmi_cec.py @@ -13,7 +13,6 @@ from homeassistant.components.media_player import ( 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 @@ class CecPlayerDevice(CecDevice, MediaPlayerDevice): """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 0b4069ed664..b70c1ffbf28 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -150,7 +150,7 @@ def setup_plexserver( _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 setup_plexserver( _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 9564a8d3df0..e3f426cc5c6 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -20,7 +20,7 @@ from homeassistant.const import ( 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 e76577a5dc8..dbda456587e 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 7e35c219c45..9757716b1bf 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 66b10532664..6093be7d091 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -827,18 +827,20 @@ class MqttAvailability(Entity): 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 @@ class MqttAvailability(Entity): 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 @@ class MqttAvailability(Entity): @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 @@ class MqttDiscoveryUpdate(Entity): 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 bf83b173972..8d5f28278d9 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -27,31 +27,25 @@ SUPPORTED_COMPONENTS = [ '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', +] + +DEPRECATED_PLATFORM_TO_SCHEMA = { + 'mqtt': 'basic', + 'mqtt_json': 'json', + 'mqtt_template': 'template', } -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'], -} ALREADY_DISCOVERED = 'mqtt_discovered_components' DATA_CONFIG_ENTRY_LOCK = 'mqtt_config_entry_lock' @@ -213,12 +207,15 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, 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_start(hass: HomeAssistantType, discovery_topic, hass_config, _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_start(hass: HomeAssistantType, discovery_topic, hass_config, 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 8be8d311d9b..26101f32f89 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -5,45 +5,90 @@ For more details about this component, please refer to the documentation at 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)) + 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 - 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) + # Go through all remaining subscriptions and unsubscribe them + for remaining in current_subscriptions.values(): + if remaining.unsubscribe_callback is not None: + remaining.unsubscribe_callback() - for key, (topic, unsub) in list(cur_state.items()): - if unsub is not None: - unsub() - - return sub_state + return new_state @bind_hass diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index 0e01310115f..2cde7825734 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -13,7 +13,7 @@ from homeassistant.core import callback 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 async_setup(hass, config): ): 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 baac86f4bf1..685bbb90cbb 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv 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 6c5fac074ba..c6ab06d6884 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -56,25 +56,68 @@ ACTION = { } 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 @@ ERRORS = { '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 00000000000..e10e6264643 --- /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 d8924c6c301..50bd290797d 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery 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 fa93cc4ba4d..771606b935f 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -242,7 +242,7 @@ class HTML5PushCallbackView(HomeAssistantView): # 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 e792045ec80..00000000000 --- 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 d576cdcc95e..599633ff5ff 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -17,7 +17,7 @@ from homeassistant.components.notify import ( 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 @@ ATTR_FILE_AUTH_DIGEST = 'digest' 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 @@ class SlackNotificationService(BaseNotificationService): '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 @@ class SlackNotificationService(BaseNotificationService): 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 376575e3440..25aca9f8afa 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -14,10 +14,6 @@ STORAGE_VERSION = 1 @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 00000000000..9f6ad4f8d47 --- /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 bd7fc3f8191..38e261ab6bd 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 00000000000..438148f414c --- /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 00000000000..9c4e46a28bf --- /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 00000000000..ba264ad4b47 --- /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 00000000000..146fda64b1e --- /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 00000000000..9f86cd12cc4 --- /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 00000000000..134c49ecbbb --- /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 00000000000..91df7f5a8ea --- /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 00000000000..bb9c7f39c5b --- /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 00000000000..e7ae5593637 --- /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 00000000000..64a6935a9b2 --- /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 00000000000..d8c195cb277 --- /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 a5da7f5fc48..7dc88be9764 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -118,9 +118,18 @@ async def async_connect_mqtt(hass, component): 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 @@ class OwnTracksContext: 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 00000000000..71f13959b41 --- /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 fcc9a92bd5e..0480b6d7195 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 00000000000..ff7f2cdcd56 --- /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 00000000000..c5e4a7b2e86 --- /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 00000000000..98fa79573b0 --- /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 00000000000..8831696fcff --- /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 00000000000..bd0ac2f1218 --- /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 7d88bfeec42..6b5cb91cfeb 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 00000000000..91a86f5e3db --- /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 36215da7893..6616d6b24ec 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -4,6 +4,7 @@ Support for Minut Point. 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 homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp from . import config_flow # noqa pylint_disable=unused-import from .const import ( - CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, NEW_DEVICE, SCAN_INTERVAL, - SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK) + 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 @@ _LOGGER = logging.getLogger(__name__) 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 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): _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 @@ class MinutPointClient(): _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 4ef21b57cd9..c6ba69a8083 100644 --- a/homeassistant/components/point/const.py +++ b/homeassistant/components/point/const.py @@ -12,4 +12,5 @@ CONF_WEBHOOK_URL = 'webhook_url' 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 bf9957f36ee..3cfa0696644 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.loader import bind_hass 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 00000000000..919956b8c34 --- /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 00000000000..2fbb55b2833 --- /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 00000000000..9891ac50f48 --- /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 00000000000..20f963d9dfb --- /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 4a714f18999..6eec3ef0eba 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 00000000000..10d05fadf93 --- /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 928c2ab2027..6e1b8b68437 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity import Entity 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 @@ PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) 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 @@ SERVICE_STOP_ZONE_SCHEMA = vol.Schema({ 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 ecf497333cb..59b27fe0099 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -7,10 +7,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import ( - CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL) + 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 @@ class RainMachineFlowHandler(config_entries.ConfigFlow): 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 ec1f0436ccb..e0e79e8c160 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -10,5 +10,6 @@ DATA_CLIENT = 'client' 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 ddb508d1282..15de4c3f995 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -28,7 +28,6 @@ import homeassistant.helpers.config_validation as cv 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 @@ CONFIG_SCHEMA = vol.Schema({ }, 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 @@ class Recorder(threading.Thread): 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 915f38745a4..a247cb3e914 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import PlatformNotReady 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 00000000000..7a36900f884 --- /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 16c9f65420c..54490af3cfa 100644 --- a/homeassistant/components/script.py +++ b/homeassistant/components/script.py @@ -14,7 +14,8 @@ import voluptuous as vol 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 @@ class ScriptEntity(ToggleEntity): 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 6e9204b80e1..8ddeb3d2ecc 100644 --- a/homeassistant/components/sense.py +++ b/homeassistant/components/sense.py @@ -8,20 +8,20 @@ import logging 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 be599cc295a..2800b689dc6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.entity_component import EntityComponent 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_CLASSES = [ 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 00000000000..bce0acb5141 --- /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 804f83de4fd..6d3ca87c4ae 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 @@ class BlinkSensor(Entity): 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 6f7bc56cca9..df8b5391359 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 @@ class BOMCurrentSensor(Entity): 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 3445eb531aa..eae0c6e9614 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -71,6 +71,11 @@ class DaikinClimateSensor(Entity): 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 a3af5631a9c..04c084784c7 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity 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 00000000000..01fb22f675c --- /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 761dc7c6a00..8e975c48574 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -10,9 +10,8 @@ import voluptuous as vol 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 setup_platform(hass, config, add_entities, discovery_info=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 @@ class SpeedtestSensor(Entity): 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 f5140838a7a..f5a45599bb7 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 87e2bdb5c9c..0fc31ef273f 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -22,7 +22,7 @@ from homeassistant.util import Throttle _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 225ed07a622..7d0908c5645 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -16,7 +16,7 @@ from homeassistant.components import sensor 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - # Integrations shouldn't never expose unique_id through configuration - # this here is an exception because MQTT is a msg transport, not a protocol + # 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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.""" + self._config = config + self._unique_id = config.get(CONF_UNIQUE_ID) + self._state = STATE_UNKNOWN + self._sub_state = None + self._expiration_trigger = None + self._attributes = 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) + 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 = 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._expiration_trigger = None - self._json_attributes = set(json_attributes) - self._unique_id = unique_id - self._attributes = None - 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._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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, # 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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, _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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSensor(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 f709e0169cf..7590bccb543 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -63,6 +63,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): """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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoSensor(Entity): 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 @@ class NetAtmoData: 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 @@ class NetAtmoData: 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 0c099c8873e..1bb46827602 100644 --- a/homeassistant/components/sensor/point.py +++ b/homeassistant/components/sensor/point.py @@ -6,13 +6,15 @@ https://home-assistant.io/components/sensor.point/ """ 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 @@ SENSOR_TYPES = { 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 00000000000..8718f3a9d74 --- /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 7822bcd58b7..8ec6a45b639 100644 --- a/homeassistant/components/sensor/rtorrent.py +++ b/homeassistant/components/sensor/rtorrent.py @@ -110,11 +110,11 @@ class RTorrentSensor(Entity): 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 b494257beb7..769b3a9e148 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -4,27 +4,31 @@ Support for monitoring a Sense energy sensor. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sense/ """ +from datetime import timedelta import logging -from datetime import timedelta - +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 @@ class SensorConfig: # 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 setup_platform(hass, config, add_entities, discovery_info=None): 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 @@ class Sense(Entity): 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 b4c869e7267..7c5dba3b0e1 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.1.0'] +REQUIREMENTS = ['py17track==2.1.1'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' @@ -25,6 +25,7 @@ ATTR_INFO_TEXT = 'info_text' 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 @@ class SeventeenTrackSummarySensor(Entity): @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 @@ class SeventeenTrackPackageSensor(Entity): 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 @@ class SeventeenTrackPackageSensor(Entity): @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 718e4f6fb0d..b9997345c36 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ from homeassistant.const import ( 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 a08eec56e17..f834b51b064 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -11,9 +11,8 @@ import voluptuous as vol 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 setup_platform(hass, config, add_entities, discovery_info=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 @@ class SpeedtestSensor(Entity): 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 e7a35b5fdf0..e011121f4a2 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -120,7 +120,7 @@ class StatisticsSensor(Entity): 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 @@ class StatisticsSensor(Entity): 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 27a7f083fbe..212602aa72c 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -41,7 +41,6 @@ SENSOR_TYPES = { '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 @@ class SystemMonitorSensor(Entity): 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 4676e08a247..4afff115b9d 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -6,7 +6,8 @@ https://home-assistant.io/components/sensor.tellduslive/ """ 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_DEW_POINT = 'dewp' 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_TYPES = { 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 @@ class TelldusLiveSensor(TelldusLiveEntity): @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 @@ class TelldusLiveSensor(TelldusLiveEntity): """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 703f2bbbd17..0ba470ca778 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 @@ class TibberSensorElPrice(Entity): 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 @@ class TibberSensorRT(Entity): 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 a3f0c55b954..65b996a5bd5 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -6,19 +6,18 @@ https://home-assistant.io/components/sensor.volvooncall/ """ 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 60da761cf75..65632f51494 100644 --- a/homeassistant/components/sensor/waterfurnace.py +++ b/homeassistant/components/sensor/waterfurnace.py @@ -42,6 +42,14 @@ SENSORS = [ "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 dddf7b23922..ef5ed1d5c38 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -14,7 +14,7 @@ from homeassistant.exceptions import PlatformNotReady 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 9a9de0d6cf2..80aad9ac937 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -7,8 +7,12 @@ at https://home-assistant.io/components/sensor.zha/ 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 @@ DEPENDENCIES = ['zha'] 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 @@ class Sensor(zha.Entity): 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 @@ class ElectricalMeasurementSensor(Sensor): """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 ad4680982b4..2ebd80c3de0 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -40,6 +40,7 @@ SERVICE_ITEM_SCHEMA = vol.Schema({ 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 @@ SCHEMA_WEBSOCKET_UPDATE_ITEM = \ 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 async_setup(hass, config): 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 4ddf405e1ed..f685297890e 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 aaa8e3a19f9..7f1f8f539eb 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers import config_validation as cv 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 3f27c91e7c5..b3c3b63bd84 100644 --- a/homeassistant/components/skybell.py +++ b/homeassistant/components/skybell.py @@ -14,7 +14,7 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['skybellpy==0.1.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 740fc1a8179..86fed8933ef 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 4726d57ad24..7811a31ebdb 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 b2697b4a2c4..1016e91d8d2 100644 --- a/homeassistant/components/switch/hdmi_cec.py +++ b/homeassistant/components/switch/hdmi_cec.py @@ -9,7 +9,6 @@ import logging 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 00000000000..d76528c56f0 --- /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 4ddafa228a7..e217d109cbc 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 00000000000..b612cd8dec7 --- /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 ad2b963629e..19e72a9d021 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.mqtt/ """ import logging -from typing import Optional import voluptuous as vol @@ -14,7 +13,7 @@ from homeassistant.components.mqtt import ( 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 @@ from homeassistant.components import mqtt, switch 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 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ 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_setup_entry(hass, config_entry, async_add_entities): 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, @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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 @@ class MqttSwitch(MqttAvailability, MqttDiscoveryUpdate, MqttEntityDeviceInfo, 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 16dfc075409..3bbe2e69110 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -13,7 +13,7 @@ from homeassistant.components import pilight 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 @@ class _ReceiveHandle: 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 @@ class PilightSwitch(SwitchDevice): 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 62636b67003..e1da12d317e 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -14,7 +14,7 @@ from homeassistant.const import ( 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 53f987c8b46..9682a4444aa 100644 --- a/homeassistant/components/switch/switchbot.py +++ b/homeassistant/components/switch/switchbot.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['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 e2ca3accdc9..23794abeba4 100644 --- a/homeassistant/components/switch/switchmate.py +++ b/homeassistant/components/switch/switchmate.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME, CONF_MAC -REQUIREMENTS = ['pySwitchmate==0.4.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 0263dfd8198..ed4f825f5ac 100644 --- a/homeassistant/components/switch/tellduslive.py +++ b/homeassistant/components/switch/tellduslive.py @@ -9,7 +9,8 @@ https://home-assistant.io/components/switch.tellduslive/ """ 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 @@ class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): 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 42c753725ab..81abf7d0e6c 100644 --- a/homeassistant/components/switch/volvooncall.py +++ b/homeassistant/components/switch/volvooncall.py @@ -8,17 +8,18 @@ https://home-assistant.io/components/switch.volvooncall/ """ 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 7e11f986b92..125f89f5040 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -17,7 +17,7 @@ from homeassistant.const import ( 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 68a94cc1ca5..4dac3bfbb22 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -7,7 +7,11 @@ at https://home-assistant.io/components/switch.zha/ 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 @@ DEPENDENCIES = ['zha'] async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the Zigbee Home Automation switches.""" + """Old way of setting up Zigbee Home Automation switches.""" + pass + + +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]) + + unsub = async_dispatcher_connect( + hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover) + hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) + + 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] + + +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) - discovery_info = zha.get_discovery_info(hass, discovery_info) - if discovery_info is None: - return - - switch = Switch(**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 - ) - - async_add_entities([switch], update_before_add=True) + async_add_entities(entities, update_before_add=True) -class Switch(zha.Entity, SwitchDevice): +class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" _domain = DOMAIN @@ -94,8 +118,8 @@ class Switch(zha.Entity, SwitchDevice): 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 366799b872c..645d67b3dc2 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -36,26 +36,27 @@ TAHOMA_COMPONENTS = [ ] TAHOMA_TYPES = { - 'rts:RollerShutterRTSComponent': 'cover', - 'rts:CurtainRTSComponent': 'cover', + 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'io:LightIOSystemSensor': 'sensor', + 'io:OnOffLightIOComponent': 'switch', + 'io:RollerShutterGenericIOComponent': 'cover', + 'io:RollerShutterUnoIOComponent': 'cover', + 'io:RollerShutterVeluxIOComponent': 'cover', + 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', + 'io:SomfyContactIOSystemSensor': 'sensor', + 'io:VerticalExteriorAwningIOComponent': 'cover', + 'io:WindowOpenerVeluxIOComponent': 'cover', + 'rtds:RTDSContactSensor': 'sensor', + 'rtds:RTDSMotionSensor': 'sensor', + 'rtds:RTDSSmokeSensor': 'smoke', 'rts:BlindRTSComponent': 'cover', - 'rts:VenetianBlindRTSComponent': 'cover', + 'rts:CurtainRTSComponent': 'cover', 'rts:DualCurtainRTSComponent': 'cover', 'rts:ExteriorVenetianBlindRTSComponent': 'cover', - 'io:ExteriorVenetianBlindIOComponent': 'cover', - 'io:RollerShutterUnoIOComponent': 'cover', - 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', - 'io:RollerShutterVeluxIOComponent': 'cover', - 'io:RollerShutterGenericIOComponent': 'cover', - 'io:WindowOpenerVeluxIOComponent': 'cover', - 'io:LightIOSystemSensor': 'sensor', 'rts:GarageDoor4TRTSComponent': 'switch', - 'io:VerticalExteriorAwningIOComponent': 'cover', - 'io:HorizontalAwningIOComponent': 'cover', - 'io:OnOffLightIOComponent': 'switch', - 'rtds:RTDSSmokeSensor': 'smoke', - 'rtds:RTDSContactSensor': 'sensor', - 'rtds:RTDSMotionSensor': 'sensor' + '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 c2b7ba9ba0f..89e74464489 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -4,26 +4,22 @@ Support for Telldus Live. 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 @@ CONFIG_SCHEMA = vol.Schema({ }), }, extra=vol.ALLOW_EXTRA) - -ATTR_LAST_UPDATED = 'time_last_updated' - CONFIG_INSTRUCTIONS = """ To link your TelldusLive account: @@ -147,7 +140,7 @@ def setup(hass, config, session=None): 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 setup(hass, config, session=None): 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 @@ class TelldusLiveClient: 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 @@ class TelldusLiveClient: 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 @@ class TelldusLiveClient: 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 00000000000..a4ef33af518 --- /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 00000000000..88b7d47ad9d --- /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 2545417e033..8462b646a22 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN, from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession -REQUIREMENTS = ['pyTibber==0.8.2'] +REQUIREMENTS = ['pyTibber==0.8.6'] DOMAIN = 'tibber' @@ -45,7 +45,11 @@ async def async_setup(hass, config): 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 c29df9db858..3f758edea86 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -12,9 +12,9 @@ import voluptuous as vol 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 @@ class Timer(Entity): 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 97a0e25d754..58782a1b421 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 c7fcfd50b56..c42ca6b7b2b 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 7b3fe4ef04e..e3f5b7407cd 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -10,9 +10,10 @@ import voluptuous as vol 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_OUTPUT_FORMAT = 'output_format' 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 = ['8000', '16000', '22050'] 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 @@ SUPPORTED_TEXT_TYPES = ['text', 'ssml'] CONTENT_TYPE_EXTENSIONS = { 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', - 'audio/pcm': 'pcm' + 'audio/pcm': 'pcm', } DEFAULT_VOICE = 'Joanna' @@ -66,7 +89,7 @@ DEFAULT_TEXT_TYPE = 'text' DEFAULT_SAMPLE_RATES = { 'mp3': '22050', 'ogg_vorbis': '22050', - 'pcm': '16000' + 'pcm': '16000', } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -78,8 +101,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 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 @@ class AmazonPollyProvider(Provider): 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 6f63614fdb7..6f9e22bfd40 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 028919bff90..8790c708008 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 e758a47064e..c195392be22 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 95ba46597da..3ea631ec86c 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 908c1c5d0c5..ca1a802a580 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 a2bf78a7f3e..fc0225cc534 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 5cb9a3f4a27..8e86c41366b 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 a491b69ca2f..943b487857f 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -21,7 +21,7 @@ from homeassistant.const import ( 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 @@ FAN_SPEEDS = { '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 @@ class MiroboVacuum(StateVacuumDevice): self.consumable_state = None self.clean_history = None self.dnd_state = None + self.last_clean = None @property def name(self): @@ -248,6 +251,10 @@ class MiroboVacuum(StateVacuumDevice): 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 @@ class MiroboVacuum(StateVacuumDevice): 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 2f2fa194846..481aa331e41 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -28,6 +28,7 @@ CONF_DOOR_WINDOW = 'door_window' 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 @@ CONFIG_SCHEMA = vol.Schema({ 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 0ce8870bedf..75339171cbc 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -14,15 +14,17 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, 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_UPDATE_INTERVAL = 'update_interval' CONF_REGION = 'region' CONF_SERVICE_URL = 'service_url' CONF_SCANDINAVIAN_MILES = 'scandinavian_miles' +CONF_MUTABLE = 'mutable' -SIGNAL_VEHICLE_SEEN = '{}.vehicle_seen'.format(DOMAIN) +SIGNAL_STATE_UPDATED = '{}.updated'.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')} +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 @@ CONFIG_SCHEMA = vol.Schema({ 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) + data.vehicles.add(vehicle.vin) - def update_vehicle(vehicle): - """Receive updated information on vehicle.""" - state.vehicles[vehicle.vin] = vehicle - if vehicle.vin not in state.entities: - discover_vehicle(vehicle) + dashboard = vehicle.dashboard( + mutable=config[DOMAIN][CONF_MUTABLE], + scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES]) - for entity in state.entities[vehicle.vin]: - entity.schedule_update_ha_state() + for instrument in ( + instrument + for instrument in dashboard.instruments + if instrument.component in COMPONENTS and + is_enabled(instrument.slug_attr)): - dispatcher_send(hass, SIGNAL_VEHICLE_SEEN, vehicle) + data.instruments.add(instrument) - def update(now): + 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 @@ class VolvoData: 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 _state(self): - return self._hass.data[DATA_KEY] + def instrument(self): + """Return corresponding instrument.""" + return self.data.instrument(self.vin, self.component, self.attribute) + + @property + 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 @@ class VolvoEntity(Entity): @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 e9024131af8..0947afea141 100644 --- a/homeassistant/components/waterfurnace.py +++ b/homeassistant/components/waterfurnace.py @@ -19,13 +19,12 @@ from homeassistant.helpers import config_validation as cv 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 @@ NOTIFICATION_TITLE = 'WaterFurnace website status' 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 @@ class WaterFurnaceData(threading.Thread): 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 4c517824bca..1ed54496c6f 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 7fecfbcd074..55a1527db8c 100644 --- a/homeassistant/components/weather/ipma.py +++ b/homeassistant/components/weather/ipma.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 ad23ba6f544..6742f33c72d 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 db41f3df06d..434775c9b9b 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -69,7 +69,7 @@ class AuthPhase: 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 @@ class AuthPhase: 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 771a6a57f4f..ff928b43873 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,6 +3,7 @@ import voluptuous as vol 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 1cb58591a0a..60e2caa54ac 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 @@ class ActiveConnection: 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 @@ class ActiveConnection: """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 8d452959ca5..fd8f7eb7b08 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -6,11 +6,12 @@ DOMAIN = 'websocket_api' 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 5f78790f5db..34250202a5e 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 13be503a009..42c2c0a5751 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -13,11 +13,12 @@ from homeassistant.core import callback 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 @@ class WebSocketHandler: 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 93760405e08..1d0133739c3 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.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 00000000000..f64d97dfc0d --- /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 00000000000..a3b097c5d35 --- /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 00000000000..1feac454c45 --- /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 00000000000..f0da251f5eb --- /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 00000000000..ffeaf4588e6 --- /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 00000000000..37304c8c8fd --- /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 00000000000..e7a3c901c21 --- /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 00000000000..9db55494ba4 --- /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 00000000000..88d4b83ca0d --- /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 00000000000..8db9f20dc7b --- /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 00000000000..cd618072592 --- /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 00000000000..888b9be2bc7 --- /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 00000000000..8befb2ee114 --- /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 00000000000..24809a59e0b --- /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 228e589ab01..fb909b6fedf 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -5,51 +5,47 @@ For more details about this component, please refer to the documentation at 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 @@ SERVICE_SCHEMAS = { # 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 @@ _LOGGER = logging.getLogger(__name__) 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 async_setup(hass, config): 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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 @@ class ApplicationListener: '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 @@ class ApplicationListener: 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 @@ class ApplicationListener: 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, + if is_new_join: + async_dispatcher_send( + self._hass, + ZHA_DISCOVERY_NEW.format(component), + discovery_info ) 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, - ) - 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 00000000000..1c903ec3056 --- /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 88dee57aa70..9efa847b50c 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 00000000000..c3c3ea163ed --- /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 00000000000..2d2a5d76b81 --- /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 00000000000..da8f615a665 --- /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 00000000000..7ae6fbf2d22 --- /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 00000000000..b6d7948c0b3 --- /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 f0619f2163c..dc408035d0f 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 b617a902374..7849f34bbf9 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 457bfd3baa8..b6856e4590a 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 dd0b36020a4..6d96192f075 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -420,7 +420,7 @@ async def async_setup_entry(hass, config_entry): 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 5f7107f95ae..4fc77bd81cd 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 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> 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 2325f35822f..5c6ced5756f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -159,6 +159,7 @@ FLOWS = [ 'twilio', 'unifi', 'upnp', + 'zha', 'zone', 'zwave' ] diff --git a/homeassistant/const.py b/homeassistant/const.py index 63d4e9f00f5..cd71c6d994c 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_HOMEASSISTANT_CLOSE = 'homeassistant_close' 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_SERVICE_REMOVED = 'service_removed' 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 @@ ATTR_ID = 'id' # 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 1754a8b5014..37d1134ef29 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,18 +25,18 @@ from typing import ( # noqa: F401 pylint: disable=unused-import 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 @@ class ServiceRegistry: """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 @@ class ServiceRegistry: 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 @@ class ServiceRegistry: 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 0613b7cb10c..5e2ab4988b1 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -58,3 +58,14 @@ class Unauthorized(HomeAssistantError): 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 687ed0b6f8b..2d4ad68dbbe 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -363,10 +363,7 @@ class Entity: 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 @@ class Entity: 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 ec7b5579342..ece0fbd071a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -346,8 +346,7 @@ class EntityPlatform: 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 c40d14652ad..57c8bcf0af8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,6 +10,7 @@ timer. from collections import OrderedDict from itertools import chain import logging +from typing import Optional import weakref import attr @@ -85,6 +86,11 @@ class EntityRegistry: """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 eb88a3db369..33b612b555a 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 + +DATA_RESTORE_STATE_TASK = 'restore_state_task' -RECORDER_TIMEOUT = 10 -DATA_RESTORE_CACHE = 'restore_state_cache' -_LOCK = 'restore_lock' _LOGGER = logging.getLogger(__name__) +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) + + +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 + + 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) -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) + 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()) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, remove_cache) + # Dump states periodically + async_track_time_interval( + self.hass, lambda *_: self.hass.async_create_task( + self.async_dump_states()), STATE_DUMP_INTERVAL) - last_run = last_recorder_run(hass) + # 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 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 + @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) - 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) + @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()) - 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])) + self.entity_ids.remove(entity_id) -@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) +class RestoreEntity(Entity): + """Mixin class for restoring previous entity state.""" - if _RECORDER not in hass.config.components: - return None + 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) - 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 + 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) - 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) - - return hass.data.get(DATA_RESTORE_CACHE, {}).get(entity_id) - - -async def async_restore_state(entity, extract_info): - """Call entity.async_restore_state with cached info.""" - if entity.hass.state not in (CoreState.starting, CoreState.not_running): - _LOGGER.debug("Not restoring state for %s: Hass is not starting: %s", - entity.entity_id, entity.hass.state) - return - - state = await async_get_last_state(entity.hass, entity.entity_id) - - if not state: - return - - await entity.async_restore_state(**extract_info(state)) + 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 80d66f4fac8..088882df608 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -9,7 +9,7 @@ import voluptuous as vol 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_WAIT_TEMPLATE = 'wait_template' 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 @@ class Script(): 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 @@ class Script(): 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) - + 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) return + 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 - 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)) - - 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) - + # Set script to not-running. self._cur = -1 self.last_action = None if self._change_listener: @@ -198,6 +166,86 @@ class Script(): 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 @@ class Script(): 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 @@ class Script(): 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 @@ class Script(): 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 cfe73d6d147..5fbb7700458 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 @@ _LOGGER = logging.getLogger(__name__) @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 @@ async def async_migrator(hass, old_path, store, *, 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 @@ class Store: 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 66f289724be..2173f972cba 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -4,6 +4,7 @@ import json import logging import math import random +import base64 import re import jinja2 @@ -169,8 +170,10 @@ class Template: 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 @@ ENV.filters['is_defined'] = fail_when_undefined 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 11f96591705..7236380d42a 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 1e77454a8d5..ac341e8f58a 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -327,11 +327,6 @@ def check_ha_config_file(hass): 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 76a9d9318f2..16c2638f26e 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ import os 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 920f45a0c0e..19b182a4cd5 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 00000000000..d648ed43110 --- /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 b002c8e3147..8ca1c702b6c 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 8211252a516..0659e3d8054 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 be06f83e6f2..a88aabe1936 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 169c54aa713..ee749157a7a 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 16c861a75fc..7fd779d4231 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 204bc67b086..8d761c1e614 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 f602c04bd79..e3ad8015a4c 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 76a9e05de33..82dab374e42 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -46,6 +46,7 @@ TEST_REQUIREMENTS = ( 'coinmarketcap', 'defusedxml', 'dsmr_parser', + 'enturclient', 'ephem', 'evohomeclient', 'feedparser', @@ -63,6 +64,7 @@ TEST_REQUIREMENTS = ( 'home-assistant-frontend', 'homematicip', 'influxdb', + 'jsonpath', 'libpurecoollink', 'libsoundtouch', 'luftdaten', @@ -91,6 +93,7 @@ TEST_REQUIREMENTS = ( 'pyspcwebgw', 'python-forecastio', 'python-nest', + 'python_awair', 'pytradfri\\[async\\]', 'pyunifi', 'pyupnp-async', @@ -108,6 +111,7 @@ TEST_REQUIREMENTS = ( 'srpenergy', 'statsd', 'uvcclient', + 'vsure', 'warrant', 'pythonwhois', 'wakeonlan', diff --git a/setup.py b/setup.py index 49147afdd70..f4da5411ed5 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,8 @@ REQUIRES = [ '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 @@ REQUIRES = [ '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 ffe0b103fc9..748b5507824 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 40de5ca7334..1fd70668f8b 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -4,12 +4,16 @@ import voluptuous as vol 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 ba6fe214146..f6a68f0865a 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 84beb8cdd3f..d3fa27b9f5b 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 b02111e8d02..329124bc979 100644 --- a/tests/auth/test_models.py +++ b/tests/auth/test_models.py @@ -5,7 +5,12 @@ from homeassistant.auth import models, permissions 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 d5056e220f0..d7b28b3039a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -114,8 +114,7 @@ def get_test_home_assistant(): # 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_test_home_assistant(loop): # 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 @@ class MockUser(auth_models.User): 'name': name, 'system_generated': system_generated, 'groups': groups or [], + 'perm_lookup': None, } if id is not None: kwargs['id'] = id @@ -403,7 +402,8 @@ class MockUser(auth_models.User): 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 @@ def mock_storage(data=None): 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 64616718125..24f1b00ee90 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 d7871e82afc..592ec585421 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -21,7 +21,7 @@ NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3" @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 alexa_client(loop, hass, aiohttp_client): }, } })) - 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 3cfb8068177..ddf66d1c617 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 e28f7be4341..1193526d2be 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 28d4c0979c4..a01b48b9190 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 a5f6a751b46..ff475376587 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -5,11 +5,11 @@ from homeassistant.bootstrap import async_setup_component 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 b981fced320..843bda0656c 100644 --- a/tests/components/camera/test_generic.py +++ b/tests/components/camera/test_generic.py @@ -7,7 +7,7 @@ from homeassistant.setup import async_setup_component @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 0a57512aabd..f2dbb294136 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -11,7 +11,7 @@ from tests.common import mock_registry @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 6d9688c10e6..be1d24ce34f 100644 --- a/tests/components/camera/test_push.py +++ b/tests/components/camera/test_push.py @@ -4,90 +4,51 @@ import io 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' + 'webhook_id': 'camera.config_test' }}) + await hass.async_block_till_done() + assert hass.states.get('camera.config_test') is not None + 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 + # wrong webhook files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.wrong', data=files) + resp = await client.post('/api/webhood/camera.wrong', data=files) assert resp.status == 404 + # missing file + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' -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' - }}) + resp = await client.post('/api/webhook/camera.config_test') + assert resp.status == 200 # webhooks always return 200 - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) - - # wrong token - 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', - }}) - - setup_auth(hass.http.app, [], True, api_password=None) - client = await aiohttp_client(hass.http.app) - - # no token - files = {'image': io.BytesIO(b'fake')} - resp = await client.post('/api/camera_push/camera.config_test', - data=files) - assert resp.status == 401 - - # 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 b41cb9f865b..476e612eb06 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -55,7 +55,12 @@ class TestUVCSetup(unittest.TestCase): 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 @@ class TestUVCSetup(unittest.TestCase): 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 @@ class TestUVCSetup(unittest.TestCase): 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 462939af23a..3a023916741 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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): 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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): """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 @@ class TestDemoClimate(unittest.TestCase): 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 2e942c5988c..2aeb1228aba 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 61b481ed4db..7beb3887ae0 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 @@ class TestMQTTClimate(unittest.TestCase): 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 @@ class TestMQTTClimate(unittest.TestCase): 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 @@ class TestMQTTClimate(unittest.TestCase): 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 00000000000..0ddb8ecce50 --- /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 00000000000..9306a6c6ef3 --- /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 4abf5b8501d..84d35f4bdd8 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 44d56566f75..baf6747aead 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 c900fc3a7a8..2133a803aef 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 f7e348e8476..b5e0a8c9197 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 homeassistant.components.config import auth as auth_config 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 2c888dd2dd2..f97559a224f 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -6,12 +6,12 @@ from homeassistant.bootstrap import async_setup_component 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 @@ async def test_get_device_config(hass, aiohttp_client): 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 @@ async def test_update_device_config(hass, aiohttp_client): 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 67d7eebbfec..0b36cc6bc87 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 5b52b3d5711..4d9063d774b 100644 --- a/tests/components/config/test_core.py +++ b/tests/components/config/test_core.py @@ -8,14 +8,14 @@ from tests.common import mock_coro @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 100a18618e6..7f81b65540f 100644 --- a/tests/components/config/test_customize.py +++ b/tests/components/config/test_customize.py @@ -9,12 +9,12 @@ from homeassistant.config import DATA_CUSTOMIZE @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 test_get_entity(hass, aiohttp_client): @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 test_update_entity(hass, aiohttp_client): @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 06ba2ff1105..52c72c60860 100644 --- a/tests/components/config/test_group.py +++ b/tests/components/config/test_group.py @@ -11,12 +11,12 @@ VIEW_NAME = 'api:config:group:config' @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 test_get_device_config(hass, aiohttp_client): @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 test_update_device_config(hass, aiohttp_client): @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 85fbf0c2e5a..547bb612ee4 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 8aae5c0a28b..71ced80eac9 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -16,12 +16,12 @@ VIEW_NAME = 'api:config:zwave:device_config' @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 d3cbdba63b4..4903e8c6455 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -3,14 +3,12 @@ from unittest.mock import patch 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 @@ def hass_ws_client(aiohttp_client): 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 36d09979d0d..2fad06027fc 100644 --- a/tests/components/counter/common.py +++ b/tests/components/counter/common.py @@ -10,12 +10,6 @@ from homeassistant.core import callback 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 c8411bf2fde..97a39cdeb73 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.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # 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}, - ] +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) - 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, - } + 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 await async_setup_component(hass, 'counter', config) + + entity_id = 'counter.test_1' + + state = hass.states.get(entity_id) + assert 0 == int(state.state) + + async_increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 1 == int(state.state) + + async_increment(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 2 == int(state.state) + + async_decrement(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 1 == int(state.state) + + async_reset(hass, entity_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert 0 == int(state.state) + + +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_1' + entity_id = 'counter.test' - state = self.hass.states.get(entity_id) - assert 0 == 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 1 == 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 2 == 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 1 == int(state.state) - - reset(self.hass, entity_id) - self.hass.block_till_done() - - state = self.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, - } - } - } - - assert setup_component(self.hass, 'counter', config) - - entity_id = 'counter.test' - - state = self.hass.states.get(entity_id) - assert 10 == int(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 15 == int(state.state) - - increment(self.hass, entity_id) - self.hass.block_till_done() - - state = self.hass.states.get(entity_id) - assert 20 == int(state.state) - - decrement(self.hass, entity_id) - self.hass.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 26204ce6ebd..df47a6caf48 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -734,25 +734,29 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 @@ class TestCoverMQTT(unittest.TestCase): 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 3c820f1a0ac..4d46882c9ea 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 @@ from homeassistant.const import ( 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.""" +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, 'test', 'automation') - 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' - }, - } +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() + await hass.async_start() + await hass.async_block_till_done() - 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') - assert state.state == STATE_OPEN + state = 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 = hass.states.async_set('cover.test_state', STATE_CLOSED) + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.set('cover.test', STATE_CLOSED) - self.hass.block_till_done() + state = hass.states.async_set('cover.test', STATE_CLOSED) + await hass.async_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() + 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 = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 42.0 - assert state.state == STATE_OPEN + state = 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 = 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 = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_position') == 0.0 - assert state.state == STATE_CLOSED + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.attributes.get('current_tilt_position') == 42.0 + state = 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' - }, - } + +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() + await hass.async_start() + await hass.async_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 + 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 - 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 %}" - } + +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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + assert hass.states.async_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 }}", - } + +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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + assert hass.states.async_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' + +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' + }, + }, + } + } + }) + + 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' + }, + } + } + } + }) + + 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' + }, + } + } + } + }) + + 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', + }, + } + } + } + }) + + 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 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() + await hass.async_start() + await hass.async_block_till_done() - assert self.hass.states.all() == [] + 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 - 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.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() + await hass.async_start() + await hass.async_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 + 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() - 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' - }, - } + 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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_CLOSED + await hass.services.async_call( + DOMAIN, SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + assert len(calls) == 1 - 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', - }, - } +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() + await hass.async_start() + await hass.async_block_till_done() - state = self.hass.states.get('cover.test_template_cover') - assert state.state == STATE_OPEN + await hass.services.async_call( + DOMAIN, SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + assert len(calls) == 1 - 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', - } - } - }) - 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 }}' - }, - }, - } +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', + }, } } - }) + } + }) + await hass.async_start() + await hass.async_block_till_done() - self.hass.start() - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('current_position') is None - 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 + 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 - 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 + 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 - 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 + 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 - 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', - }, - } +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', + }, } } - }) + } + }) + await hass.async_start() + await hass.async_block_till_done() - self.hass.start() - self.hass.block_till_done() + state = 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() + 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 - assert len(self.calls) == 1 + 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 - 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.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() + await hass.async_start() + await hass.async_block_till_done() - self.hass.services.call( - DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('icon') == '' - assert len(self.calls) == 1 + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - 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', - }, - } + 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() - self.hass.services.call( - DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True) - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') + assert state.attributes.get('entity_picture') == '' - assert len(self.calls) == 1 + state = hass.states.async_set('cover.test_state', STATE_OPEN) + await hass.async_block_till_done() - 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', - }, - } - } - } - }) - self.hass.start() - self.hass.block_till_done() + state = hass.states.get('cover.test_template_cover') - 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', - }, - } - } - } - }) - 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 %}" - } - } - } - }) - - 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 %}" - } - } - } - }) - - self.hass.start() - self.hass.block_till_done() - - state = self.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 = self.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 b83756f6ebb..5fa8ddcfe38 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 00000000000..b76eb9a8332 --- /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 93de359610f..6f0d881d257 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.""" +@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) - 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) +async def test_is_on(hass): + """Test is_on method.""" + entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') - # pylint: disable=invalid-name - def tearDown(self): - """Stop everything that was started.""" - if os.path.isfile(self.yaml_devices): - os.remove(self.yaml_devices) + hass.states.async_set(entity_id, STATE_HOME) - self.hass.stop() + assert device_tracker.is_on(hass, entity_id) - def test_is_on(self): - """Test is_on method.""" - entity_id = device_tracker.ENTITY_ID_FORMAT.format('test') + hass.states.async_set(entity_id, STATE_NOT_HOME) - self.hass.states.set(entity_id, STATE_HOME) + assert not device_tracker.is_on(hass, entity_id) - assert device_tracker.is_on(self.hass, entity_id) - self.hass.states.set(entity_id, STATE_NOT_HOME) +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) == [] - assert not device_tracker.is_on(self.hass, entity_id) + 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' - # 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 = 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' - 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' +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 - 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) + +# 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'}) + + +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) - 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 + 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() - # 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' + assert STATE_HOME == \ + hass.states.get('device_tracker.dev1').state - 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) + scanner.leave_home('DEV1') - _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' + 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() - 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) + assert STATE_NOT_HOME == \ + hass.states.get('device_tracker.dev1').state - 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 +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' - @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'}) + 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) - def test_update_stale(self): - """Test stalled update.""" - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - scanner.come_home('DEV1') + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) - register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC) - scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC) + attrs = hass.states.get(entity_id).attributes - 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 friendly_name == attrs.get(ATTR_FRIENDLY_NAME) + assert icon == attrs.get(ATTR_ICON) + assert picture == attrs.get(ATTR_ENTITY_PICTURE) - assert STATE_HOME == \ - self.hass.states.get('device_tracker.dev1').state - scanner.leave_home('DEV1') +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) - 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() + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() - assert STATE_NOT_HOME == \ - self.hass.states.get('device_tracker.dev1').state + with assert_setup_component(1, device_tracker.DOMAIN): + assert await async_setup_component(hass, device_tracker.DOMAIN, + TEST_PLATFORM) - 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' + assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN) - 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) - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) +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) - attrs = self.hass.states.get(entity_id).attributes + scanner = get_component(hass, 'device_tracker.test').SCANNER + scanner.reset() - assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME) - assert icon == attrs.get(ATTR_ICON) - assert picture == attrs.get(ATTR_ENTITY_PICTURE) + 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() - 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) + 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) - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - with assert_setup_component(1, device_tracker.DOMAIN): - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) - - 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) - - scanner = get_component(self.hass, 'device_tracker.test').SCANNER - scanner.reset() - - 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' - } +@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' } - 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) + } + 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) - mock_see.reset_mock() - params['dev_id'] += chr(233) # e' acute accent from icloud + 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) + 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) - 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 = [] - @callback - def listener(event): - """Record that our event got called.""" - test_events.append(event) +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 = [] - self.hass.bus.listen("device_tracker_new_device", listener) + @callback + def listener(event): + """Record that our event got called.""" + test_events.append(event) - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_1', host_name='hello') + hass.bus.async_listen("device_tracker_new_device", listener) - self.hass.block_till_done() + common.async_see(hass, 'mac_1', host_name='hello') + common.async_see(hass, 'mac_1', host_name='hello') - assert len(test_events) == 1 + await hass.async_block_till_done() - # Assert we can serialize the event - json.dumps(test_events[0].as_dict(), cls=JSONEncoder) + assert len(test_events) == 1 - assert test_events[0].data == { - 'entity_id': 'device_tracker.hello', - 'host_name': 'hello', - 'mac': 'MAC_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 } - # pylint: disable=invalid-name - def test_not_write_duplicate_yaml_keys(self): - """Test that the device tracker will not generate invalid YAML.""" + 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 setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + 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() - device_tracker.see(self.hass, 'mac_1', host_name='hello') - device_tracker.see(self.hass, 'mac_2', host_name='hello') + 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 - self.hass.block_till_done() + scanner.leave_home('dev1') - config = device_tracker.load_config(self.yaml_devices, self.hass, - timedelta(seconds=0)) - assert len(config) == 2 + 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() - # 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) + 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 - 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 +@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, {}, []) - def test_see_state(self): - """Test device tracker see records state correctly.""" - assert setup_component(self.hass, device_tracker.DOMAIN, - TEST_PLATFORM) + # MAC is not a string (but added) + await tracker.async_see(mac=567, host_name="Number MAC") - 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, - }, - } + # No device id or MAC(not added) + with pytest.raises(HomeAssistantError): + await tracker.async_see() + assert mock_warning.call_count == 0 - device_tracker.see(self.hass, **params) - self.hass.block_till_done() + # 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 - 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 + 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 7cfef8f5219..a167a1e9fd4 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 925ba6d66db..582f112f69c 100644 --- a/tests/components/device_tracker/test_meraki.py +++ b/tests/components/device_tracker/test_meraki.py @@ -13,7 +13,7 @@ from homeassistant.components.device_tracker.meraki import URL @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 e760db151df..abfa32ca06b 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.""" +@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) - 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 +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 - 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: - 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, { + assert await async_setup_component(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 + assert mock_sp.call_count == 1 - 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' - 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 +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' - 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' + 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 - 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_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' +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' - 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 + 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 - 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, { - 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 +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 44d687a4d45..252d40338fc 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 @@ LOCATION_MESSAGE_INCOMPLETE = { 'longitude': 2.0} -class TestComponentsDeviceTrackerJSONMQTT(unittest.TestCase): - """Test JSON MQTT device tracker platform.""" +@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) - 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 +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 - 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: - 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, { + 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: 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 + assert mock_sp.call_count == 1 - 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} - } - }) +async def test_json_message(hass): + """Test json location message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = json.dumps(LOCATION_MESSAGE) - 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] + 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 - def test_incomplete_message(self): - """Test receiving an incomplete message.""" - dev_id = 'zanzito' - topic = 'location/zanzito' - location = json.dumps(LOCATION_MESSAGE_INCOMPLETE) - assert setup_component(self.hass, device_tracker.DOMAIN, { - device_tracker.DOMAIN: { - CONF_PLATFORM: 'mqtt_json', - 'devices': {dev_id: topic} - } - }) +async def test_non_json_message(hass, caplog): + """Test receiving a non JSON message.""" + dev_id = 'zanzito' + topic = 'location/zanzito' + location = 'home' - 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] + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) - 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) + 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 - 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) +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 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 + assert await async_setup_component(hass, device_tracker.DOMAIN, { + device_tracker.DOMAIN: { + CONF_PLATFORM: 'mqtt_json', + 'devices': {dev_id: topic} + } + }) - 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) + 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 - 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() - 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) +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 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() - assert self.hass.states.get(entity_id) is None + 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 b50d1c67511..8f226f449b0 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 @@ from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, 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 6e2830eee52..1b1dc1a7cb5 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 @@ from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME, CONF_HOST) from tests.common import ( - get_test_home_assistant, assert_setup_component, - mock_component, load_fixture) + assert_setup_component, mock_component, load_fixture) + +scanner_path = 'homeassistant.components.device_tracker.' + \ + 'unifi_direct.UnifiDeviceScanner' -class TestComponentsDeviceTrackerUnifiDirect(unittest.TestCase): - """Tests for the Unifi direct device tracker platform.""" +@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) - 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', +@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) + 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]) + 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) - } + +@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") + } + 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') - 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) - } +@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 + 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') - 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) - } + +@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(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 + 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 - 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_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_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 9c7c13ee741..7b141159256 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 import DOMAIN, xiaomi as xiaomi 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 mocked_requests(*args, **kwargs): _LOGGER.debug('UNKNOWN ROUTE') -class TestXiaomiDeviceScanner(unittest.TestCase): - """Xiaomi device scanner test class.""" +@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' - 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() +@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' - @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) +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) - 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) +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) - 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('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 9c549f00ee8..0a82dc3513d 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -6,10 +6,8 @@ from unittest.mock import patch 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 @@ BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}' 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 @@ class TestEmulatedHue(unittest.TestCase): """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 a3f76058c76..a3e8b0e9f32 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 2e78e0441a3..9f386ceb904 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 f476598adc9..46d1ed630c4 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 = { ] } +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 _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 - 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 +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)) - @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)) + # 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] + 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() - 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 = hass.states.async_all() + assert len(all_states) == 3 - all_states = self.hass.states.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 = 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 = 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_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 - 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] + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 3 - 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 + async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 3 - 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 + async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) + await hass.async_block_till_done() - # 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 = hass.states.async_all() + assert len(all_states) == 0 - 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. +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)) - # 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 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) + with assert_setup_component(1, geo_location.DOMAIN): + assert await async_setup_component( + hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION) - # 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 + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - def mock_delete_callback(entity): - original_delete_callback(entity) + all_states = hass.states.async_all() + assert len(all_states) == 1 - with patch('homeassistant.components.geo_location' - '.geo_json_events.GeoJsonLocationEvent' - '._delete_callback', - side_effect=mock_delete_callback, - autospec=True) as mocked_delete_callback: + assert mock_feed.call_args == call( + (15.1, 25.2), URL, filter_radius=200.0) - # 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 +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. - # 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() + # 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') - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + # 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) - # 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() + mock_feed.return_value.update.return_value = 'OK', [mock_entry_1] - all_states = self.hass.states.all() - assert len(all_states) == 1 + # Artificially trigger update. + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + # Collect events. + await hass.async_block_till_done() - # 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 = 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 - 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 + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() - # Reset mocked method for the next test. - mocked_delete_callback.reset_mock() + 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 - 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() + # 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() - assert mocked_delete_callback.call_count == 1 - all_states = self.hass.states.all() - assert len(all_states) == 0 + 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 75397d27383..3254fd570ce 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 @@ from homeassistant.components.geo_location.nsw_rural_fire_service_feed import \ 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() - 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 - 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_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_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 - 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() - # 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 - 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() - # 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 - 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() - # 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 - 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 + + 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 00000000000..f0383c221c4 --- /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 442660c2daf..ae90af61ced 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -104,8 +104,14 @@ BEACON_EXIT_CAR = { } +@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 1568919a9b4..03cc327a5c5 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -141,7 +141,10 @@ DEMO_DEVICES = [{ '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 @@ DEMO_DEVICES = [{ '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 @@ DEMO_DEVICES = [{ '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 @@ DEMO_DEVICES = [{ '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 047fad3574c..89e9090da98 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 616c43464a6..e9169c9bbbe 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 4370c011891..07db126312b 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 51fca931faa..62e7278ba1f 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 222e8ced6e7..304bb4de997 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 ac0e23edd64..395849f066e 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 -import pytest +from unittest.mock import Mock -from homeassistant.components.http.view import HomeAssistantView +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 + + +@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(object) in caplog.text + assert str(float("NaN")) 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 f09f3726252..9e4fa3ebc79 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 03a3927472a..8567dfd7921 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -93,18 +93,19 @@ from homeassistant.setup import async_setup_component 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 6bc0b4536ea..ce4a5f5a2e6 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -31,11 +31,13 @@ from unittest.mock import patch 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 00000000000..03dd202e838 --- /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 e296d14c6f8..15548b28cfb 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 -""" +from homeassistant.components import frontend, lovelace -def test_add_id(): - """Test if id is added.""" - yaml = YAML(typ='rt') +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' + } - 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() + # 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' - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result'] == {'hello': 'world'} + # 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_deprecated_lovelace_ui_not_found(hass, hass_ws_client): - """Test lovelace_ui command cannot find file.""" - await async_setup_component(hass, 'lovelace') +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) - with patch('homeassistant.components.lovelace.load_config', - side_effect=FileNotFoundError): + # 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': 5, - 'type': 'frontend/lovelace_config', + 'id': 7, + 'type': 'lovelace/config' }) - msg = await client.receive_json() + response = 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'] + 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 2c69a5effa7..de0ee2f0b3e 100644 --- a/tests/components/mailbox/test_init.py +++ b/tests/components/mailbox/test_init.py @@ -9,7 +9,7 @@ import homeassistant.components.mailbox as mailbox @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 3f4d4cb9f24..2174967eae5 100644 --- a/tests/components/media_player/common.py +++ b/tests/components/media_player/common.py @@ -11,9 +11,9 @@ from homeassistant.components.media_player import ( 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 e986ac02065..b213cf0b5c1 100644 --- a/tests/components/media_player/test_demo.py +++ b/tests/components/media_player/test_demo.py @@ -3,6 +3,9 @@ import unittest 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 @@ class TestDemoMediaPlayer(unittest.TestCase): 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 00000000000..951f1319cc0 --- /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 417cd42187f..c6a6b3036d9 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -223,7 +223,7 @@ class TestMonopriceMediaPlayer(unittest.TestCase): # 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 5d7afbde843..81e6a7b298d 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -113,11 +113,12 @@ class TestMQTTComponent(unittest.TestCase): """ 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 57397e21ba2..4c3f3bf3f73 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 @@ class TestNotifyDemo(unittest.TestCase): 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 486300679b7..08210ecd9a2 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -49,7 +49,7 @@ REGISTER_URL = '/api/notify.html5' 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 @@ class TestHtml5Notify: 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 57a81a78da3..483b917a63e 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) + assert onboarding.async_is_onboarded(hass) - with patch('homeassistant.auth.AuthManager.active', return_value=True): - 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 ee79c8b9e10..3d2d8d03e7c 100644 --- a/tests/components/owntracks/test_init.py +++ b/tests/components/owntracks/test_init.py @@ -33,6 +33,12 @@ LOCATION_MESSAGE = { } +@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 == {'error': 'You need to supply username.'} + 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 == [] + 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 93da4ec109b..d008f868466 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 sqlalchemy.pool import StaticPool 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 00000000000..b539bdbfe7d --- /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 50669f5a77d..fc2722f9742 100644 --- a/tests/components/sensor/test_bom.py +++ b/tests/components/sensor/test_bom.py @@ -6,11 +6,12 @@ from unittest.mock import patch 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 @@ class TestBOMWeatherSensor(unittest.TestCase): 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 00000000000..20b50ce9ddd --- /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 15042805a66..78de05e1ff3 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 5d1137c35e6..9b4e53dbab9 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -40,7 +40,7 @@ class TestStatisticsSensor(unittest.TestCase): 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 @@ class TestStatisticsSensor(unittest.TestCase): '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 5cdd7d23063..a37572cc992 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 import switch, mqtt 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 + } + }) - def test_controlling_state_via_topic(self): - """Test the controlling state via topic.""" - assert setup_component(self.hass, switch.DOMAIN, { + 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 + + +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': 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, { - 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 = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + state = hass.states.get('switch.test') + assert STATE_OFF == state.state + assert not state.attributes.get(ATTR_ASSUMED_STATE) - fire_mqtt_message(self.hass, 'availability_topic', 'online') - self.hass.block_till_done() + 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_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, 'state-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_UNAVAILABLE == state.state - fire_mqtt_message(self.hass, 'state-topic', '1') - 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, 'availability_topic', 'online') - self.hass.block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == 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 + } + }) - 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_UNAVAILABLE == state.state - state = self.hass.states.get('switch.test') - assert STATE_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'good') - self.hass.block_till_done() + 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', 'offline') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'nogood') - 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_UNAVAILABLE == 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', '1') - 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_UNAVAILABLE == state.state + async_fire_mqtt_message(hass, 'availability_topic', 'online') + await hass.async_block_till_done() + await hass.async_block_till_done() - fire_mqtt_message(self.hass, 'availability_topic', 'good') - 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_ON == state.state - 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", - } - }) +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' + } + }) - 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, 'state-topic', 'HIGH') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', 'good') + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_ON == state.state + state = 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', 'LOW') - self.hass.block_till_done() + async_fire_mqtt_message(hass, 'availability_topic', 'nogood') + await hass.async_block_till_done() + await hass.async_block_till_done() - state = self.hass.states.get('switch.test') - assert STATE_OFF == state.state + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == state.state + + async_fire_mqtt_message(hass, 'state-topic', '1') + await hass.async_block_till_done() + await hass.async_block_till_done() + + state = hass.states.get('switch.test') + assert STATE_UNAVAILABLE == 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 76610421563..9fda58c37a3 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 0bc89292855..a88c828efe8 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -6,6 +6,7 @@ from unittest.mock import patch 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 b9f63922ba3..f1ef6aa5dd0 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.""" +@pytest.fixture +def scanner(hass): + """Initialize components.""" + scanner = loader.get_component( + hass, 'device_tracker.test').get_scanner(None, None) - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + scanner.reset() + scanner.come_home('DEV1') - self.scanner = loader.get_component( - self.hass, 'device_tracker.test').get_scanner(None, None) + loader.get_component(hass, 'light.test').init() - 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, { + 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) - - self.hass.block_till_done() - - 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() - - assert light.is_on(self.hass) - - def test_lights_turn_off_when_everyone_leaves(self): - """Test lights turn off when everyone leaves the house.""" - common_light.turn_on(self.hass) - - self.hass.block_till_done() - - assert setup_component( - self.hass, device_sun_light_trigger.DOMAIN, { +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.states.set(device_tracker.ENTITY_ID_ALL_DEVICES, - STATE_NOT_HOME) + common_light.async_turn_off(hass) - self.hass.block_till_done() + await hass.async_block_till_done() - assert not 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_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 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_off_when_everyone_leaves(hass, scanner): + """Test lights turn off when everyone leaves the house.""" + common_light.async_turn_on(hass) - self.hass.block_till_done() - assert light.is_on(self.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_ALL_DEVICES, + STATE_NOT_HOME) + + await hass.async_block_till_done() + + assert not light.is_on(hass) + + +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) + + 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 641dff3b4e6..0c9062414e7 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 5de6e164750..d74ec41b749 100644 --- a/tests/components/test_influxdb.py +++ b/tests/components/test_influxdb.py @@ -3,8 +3,6 @@ import datetime 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): } 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 @@ class TestInfluxDB(unittest.TestCase): 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 a61cefe34f2..2a4d0fef09d 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 0d204773241..321a16ae64e 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -4,12 +4,16 @@ import logging 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 @@ class TestComponentLogbook(unittest.TestCase): 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 @@ class TestComponentLogbook(unittest.TestCase): }, 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 49744421c72..68e7602b228 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -7,14 +7,14 @@ import homeassistant.components.prometheus as prometheus @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 64876dbea44..391004598e7 100644 --- a/tests/components/test_rss_feed_template.py +++ b/tests/components/test_rss_feed_template.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component @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 5b7d0dfb70f..790d5c2e844 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 @@ class TestScriptComponent(unittest.TestCase): 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 1e89287bcc1..f4095b77316 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 bc044999bdd..977cd966981 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 abaf7dd6d14..cb9f05ba47b 100644 --- a/tests/components/test_wake_on_lan.py +++ b/tests/components/test_wake_on_lan.py @@ -3,6 +3,7 @@ import asyncio 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 afd2b1412dc..62a57efb040 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.components.timer import ( 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.""" +async def test_config(hass): + """Test config.""" + invalid_configs = [ + None, + 1, + {}, + {'name with space': None}, + ] - # pylint: disable=invalid-name - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() + for cfg in invalid_configs: + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) - # 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}, - ] +async def test_config_options(hass): + """Test configuration options.""" + count_start = len(hass.states.async_entity_ids()) - for cfg in invalid_configs: - assert not setup_component(self.hass, DOMAIN, {DOMAIN: cfg}) + _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids()) - 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, - } + 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 70cbbc15c91..977b0669880 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.tts.demo import DemoProvider 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 @@ class TestTTS: 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 66116db8cda..d8c9c71935b 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 @@ class TestDemowater_heater(unittest.TestCase): """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 @@ class TestDemowater_heater(unittest.TestCase): 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 b7825600cb1..51d98df7f60 100644 --- a/tests/components/websocket_api/conftest.py +++ b/tests/components/websocket_api/conftest.py @@ -9,9 +9,10 @@ from . import API_PASSWORD @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 ed54b509aaa..4c0014e4783 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -13,7 +13,7 @@ from tests.common import mock_coro 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 84c29533859..78a5bf6d57e 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 @@ async def test_call_service(hass, websocket_client): 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 - }) - - auth_msg = await ws.receive_json() - assert auth_msg['type'] == TYPE_AUTH_OK - - await ws.send_json({ - 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, - 'domain': 'domain_test', - 'service': 'test_service', - 'service_data': { - 'hello': 'world' +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, + }) - msg = await ws.receive_json() - assert msg['success'] + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + 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 + assert len(msg['result']) == 1 + assert msg['result'][0]['entity_id'] == 'test.entity' + + +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 00000000000..23d26b50312 --- /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 00000000000..e46f1849fa1 --- /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 d4077345649..85cca89eefc 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -947,7 +947,7 @@ class TestZWaveServices(unittest.TestCase): 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 84b72189a8d..82ae596fb48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,10 +10,12 @@ import requests_mock as _requests_mock 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 @@ def mock_device_tracker_conf(): 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 00000000000..674c0662197 --- /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 00000000000..05ad8371232 --- /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 00000000000..899ad4eed72 --- /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 00000000000..24eafe94b23 --- /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 699342381f9..5cd77eee707 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -14,7 +14,7 @@ from tests.common import get_test_home_assistant @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 15dda24a529..b13bc87421b 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): +async 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'), + 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]) - assert DATA_RESTORE_CACHE in hass.data - assert hass.data[DATA_RESTORE_CACHE] == {st.entity_id: st for st in states} + # Emulate a fresh load + hass.data[DATA_RESTORE_STATE_TASK] = None + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + + # 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) - - yield from hass.async_block_till_done() - - assert DATA_RESTORE_CACHE not in hass.data + assert mock_write_data.called -@asyncio.coroutine -def test_hass_running(hass): - """Test that cache cannot be accessed while hass is running.""" - mock_component(hass, 'recorder') +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 + + entity = RestoreEntity() + entity.hass = hass + entity.entity_id = 'input_boolean.b1' + + # Mock that only b1 is present this run + states = [ + State('input_boolean.b1', '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() + + 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 + + # 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() + + # Assert that this session states were written + assert mock_write_data.called + + +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'), ] - 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') + 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'), + ] + + 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) + + 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() + + assert mock_write_data.called + + +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() + assert state is None -@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 +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() - states = [State('input_boolean.b1', 'on')] + hass.states.async_set('input_boolean.b0', 'on') - 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 + data = await RestoreStateData.async_get_instance(hass) + # No last states should currently be saved + assert not data.last_states -@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 + await entity.async_will_remove_from_hass() - states = [State('input_boolean.b1', 'on')] - - 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 - - -@asyncio.coroutine -def test_cache_timeout(hass): - """Test that cache timeout returns none.""" - mock_component(hass, 'recorder') - hass.state = CoreState.starting - - states = [State('input_boolean.b1', 'on')] - - @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 - - -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 - - init_recorder_component(hass) - - _add_data_in_last_run(hass, { - test_entity_id1: 'on', - test_entity_id2: 'off', - }) - - hass.block_till_done() - setup_component(hass, input_boolean.DOMAIN, { - input_boolean.DOMAIN: { - 'b1': None, - 'b2': None, - }}) - - hass.start() - - state = hass.states.get('input_boolean.b1') - assert state - assert state.state == 'on' - - state = hass.states.get('input_boolean.b2') - assert state - assert state.state == 'off' - - 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 e5e62d2aed3..887a147c417 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -4,6 +4,10 @@ from datetime import timedelta 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 @@ class TestScriptHelper(unittest.TestCase): 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 a4e9a571943..8fca7df69c1 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 38b8a7cd380..7c713082372 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 573a9f78b72..02331c400d3 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -274,6 +274,37 @@ class TestHelpersTemplate(unittest.TestCase): 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 28438a5e4b3..217f26e71f7 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -79,45 +79,6 @@ class TestCheckConfig(unittest.TestCase): 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 056bf30efe5..212fc247eb9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,8 +6,10 @@ import unittest 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 @@ from homeassistant.components.config.customize import ( 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 @@ class TestConfig(unittest.TestCase): ).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 69cde6c1403..5ee9f5cdb05 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,6 +8,7 @@ from unittest.mock import patch, MagicMock from datetime import datetime, timedelta from tempfile import TemporaryDirectory +import voluptuous as vol import pytz import pytest @@ -21,7 +22,7 @@ from homeassistant.const import ( __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 @@ class TestServiceRegistry(unittest.TestCase): 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 test_service_executed_with_subservices(hass): 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 d662f3b1955..f5bf0b8a4f8 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -177,6 +177,11 @@ class AiohttpClientMockResponse: """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 00000000000..8f528376cce --- /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 414a9f400aa..a7df74d9225 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 @@ class TestJSON(unittest.TestCase): 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")