diff --git a/.coveragerc b/.coveragerc index 058276b22e9..35c47de4160 100644 --- a/.coveragerc +++ b/.coveragerc @@ -756,7 +756,6 @@ omit = homeassistant/components/twentemilieu/sensor.py homeassistant/components/twilio_call/notify.py homeassistant/components/twilio_sms/notify.py - homeassistant/components/twitch/sensor.py homeassistant/components/twitter/notify.py homeassistant/components/ubee/device_tracker.py homeassistant/components/ubus/device_tracker.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 250b95eafda..723e3c512e2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,5 +1,6 @@ """Provide methods to bootstrap a Home Assistant instance.""" import asyncio +import contextlib import logging import logging.handlers import os @@ -7,12 +8,14 @@ import sys from time import monotonic from typing import Any, Dict, Optional, Set +from async_timeout import timeout import voluptuous as vol from homeassistant import config as conf_util, config_entries, core, loader from homeassistant.components import http from homeassistant.const import ( EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_STOP, REQUIRED_NEXT_PYTHON_DATE, REQUIRED_NEXT_PYTHON_VER, ) @@ -80,8 +83,7 @@ async def async_setup_hass( config_dict = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: _LOGGER.error( - "Failed to parse configuration.yaml: %s. Falling back to safe mode", - err, + "Failed to parse configuration.yaml: %s. Activating safe mode", err, ) else: if not is_virtual_env(): @@ -93,8 +95,30 @@ async def async_setup_hass( finally: clear_secret_cache() - if safe_mode or config_dict is None or not basic_setup_success: + if config_dict is None: + safe_mode = True + + elif not basic_setup_success: + _LOGGER.warning("Unable to set up core integrations. Activating safe mode") + safe_mode = True + + elif "frontend" not in hass.config.components: + _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + # Ask integrations to shut down. It's messy but we can't + # do a clean stop without knowing what is broken + hass.async_track_tasks() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP, {}) + with contextlib.suppress(asyncio.TimeoutError): + async with timeout(10): + await hass.async_block_till_done() + + safe_mode = True + hass = core.HomeAssistant() + hass.config.config_dir = config_dir + + if safe_mode: _LOGGER.info("Starting in safe mode") + hass.config.safe_mode = True http_conf = (await http.async_get_last_config(hass)) or {} @@ -283,7 +307,7 @@ def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]: domains = set(key.split(" ")[0] for key in config.keys() if key != core.DOMAIN) # Add config entry domains - if "safe_mode" not in config: + if not hass.config.safe_mode: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 2f37d941ac8..8ca42beab61 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "requirements": ["pyatv==0.3.13"], "dependencies": ["configurator"], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 5d27efa285e..67e177d11d9 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -225,6 +225,13 @@ class AugustData: self._door_state_by_id = {} self._activities_by_id = {} + # We check the locks right away so we can + # remove inoperative ones + self._update_locks_status() + self._update_locks_detail() + + self._filter_inoperative_locks() + @property def house_ids(self): """Return a list of house_ids.""" @@ -352,6 +359,14 @@ class AugustData: self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc return True + def lock_has_doorsense(self, lock_id): + """Determine if a lock has doorsense installed and can tell when the door is open or closed.""" + # We do not update here since this is not expected + # to change until restart + if self._lock_detail_by_id[lock_id] is None: + return False + return self._lock_detail_by_id[lock_id].doorsense + async def async_get_lock_status(self, lock_id): """Return status if the door is locked or unlocked. @@ -497,6 +512,33 @@ class AugustData: device_id, ) + def _filter_inoperative_locks(self): + # Remove non-operative locks as there must + # be a bridge (August Connect) for them to + # be usable + operative_locks = [] + for lock in self._locks: + lock_detail = self._lock_detail_by_id.get(lock.device_id) + if lock_detail is None: + _LOGGER.info( + "The lock %s could not be setup because the system could not fetch details about the lock.", + lock.device_name, + ) + elif lock_detail.bridge is None: + _LOGGER.info( + "The lock %s could not be setup because it does not have a bridge (Connect).", + lock.device_name, + ) + elif not lock_detail.bridge.operative: + _LOGGER.info( + "The lock %s could not be setup because the bridge (Connect) is not operative.", + lock.device_name, + ) + else: + operative_locks.append(lock) + + self._locks = operative_locks + def _call_api_operation_that_requires_bridge( device_name, operation_name, func, *args, **kwargs diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 4a4f0ece39b..aed1995d592 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -12,7 +12,7 @@ from . import DATA_AUGUST _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=5) async def _async_retrieve_door_state(data, lock): @@ -51,11 +51,15 @@ async def _async_activity_time_based_state(data, doorbell, activity_types): if latest is not None: start = latest.activity_start_time - end = latest.activity_end_time + timedelta(seconds=30) + end = latest.activity_end_time + timedelta(seconds=45) return start <= datetime.now() <= end return None +SENSOR_NAME = 0 +SENSOR_DEVICE_CLASS = 1 +SENSOR_STATE_PROVIDER = 2 + # sensor_type: [name, device_class, async_state_provider] SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _async_retrieve_door_state]} @@ -73,18 +77,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for door in data.locks: for sensor_type in SENSOR_TYPES_DOOR: - async_state_provider = SENSOR_TYPES_DOOR[sensor_type][2] - if await async_state_provider(data, door) is LockDoorStatus.UNKNOWN: + if not data.lock_has_doorsense(door.device_id): _LOGGER.debug( "Not adding sensor class %s for lock %s ", - SENSOR_TYPES_DOOR[sensor_type][1], + SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS], door.device_name, ) continue _LOGGER.debug( "Adding sensor class %s for %s", - SENSOR_TYPES_DOOR[sensor_type][1], + SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS], door.device_name, ) devices.append(AugustDoorBinarySensor(data, sensor_type, door)) @@ -93,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for sensor_type in SENSOR_TYPES_DOORBELL: _LOGGER.debug( "Adding doorbell sensor class %s for %s", - SENSOR_TYPES_DOORBELL[sensor_type][1], + SENSOR_TYPES_DOORBELL[sensor_type][SENSOR_DEVICE_CLASS], doorbell.device_name, ) devices.append(AugustDoorbellBinarySensor(data, sensor_type, doorbell)) @@ -125,22 +128,25 @@ class AugustDoorBinarySensor(BinarySensorDevice): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOOR[self._sensor_type][1] + return SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_DEVICE_CLASS] @property def name(self): """Return the name of the binary sensor.""" return "{} {}".format( - self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][0] + self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME] ) async def async_update(self): """Get the latest state of the sensor and update activity.""" - async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2] - self._state = await async_state_provider(self._data, self._door) - self._available = self._state is not None - - self._state = self._state == LockDoorStatus.OPEN + async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][ + SENSOR_STATE_PROVIDER + ] + lock_door_state = await async_state_provider(self._data, self._door) + self._available = ( + lock_door_state is not None and lock_door_state != LockDoorStatus.UNKNOWN + ) + self._state = lock_door_state == LockDoorStatus.OPEN door_activity = await self._data.async_get_latest_device_activity( self._door.device_id, ActivityType.DOOR_OPERATION @@ -193,7 +199,8 @@ class AugustDoorBinarySensor(BinarySensorDevice): def unique_id(self) -> str: """Get the unique of the door open binary sensor.""" return "{:s}_{:s}".format( - self._door.device_id, SENSOR_TYPES_DOOR[self._sensor_type][0].lower() + self._door.device_id, + SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME].lower(), ) @@ -221,25 +228,31 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): @property def device_class(self): """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOORBELL[self._sensor_type][1] + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_DEVICE_CLASS] @property def name(self): """Return the name of the binary sensor.""" return "{} {}".format( - self._doorbell.device_name, SENSOR_TYPES_DOORBELL[self._sensor_type][0] + self._doorbell.device_name, + SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME], ) async def async_update(self): """Get the latest state of the sensor.""" - async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2] + async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ + SENSOR_STATE_PROVIDER + ] self._state = await async_state_provider(self._data, self._doorbell) - self._available = self._doorbell.is_online + # The doorbell will go into standby mode when there is no motion + # for a short while. It will wake by itself when needed so we need + # to consider is available or we will not report motion or dings + self._available = self._doorbell.is_online or self._doorbell.status == "standby" @property def unique_id(self) -> str: """Get the unique id of the doorbell sensor.""" return "{:s}_{:s}".format( self._doorbell.device_id, - SENSOR_TYPES_DOORBELL[self._sensor_type][0].lower(), + SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower(), ) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 1e64ef59944..9d5df1192a7 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -67,7 +67,9 @@ class AugustLock(LockDevice): async def async_update(self): """Get the latest state of the sensor and update activity.""" self._lock_status = await self._data.async_get_lock_status(self._lock.device_id) - self._available = self._lock_status is not None + self._available = ( + self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN + ) self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) lock_activity = await self._data.async_get_latest_device_activity( diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index fb0073c78d5..046cbba2873 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -11,8 +11,10 @@ import homeassistant.helpers.config_validation as cv # mypy: allow-untyped-defs CONF_ENCODING = "encoding" +CONF_QOS = "qos" CONF_TOPIC = "topic" DEFAULT_ENCODING = "utf-8" +DEFAULT_QOS = 0 TRIGGER_SCHEMA = vol.Schema( { @@ -20,6 +22,9 @@ TRIGGER_SCHEMA = vol.Schema( vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_PAYLOAD): cv.string, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( + vol.Coerce(int), vol.In([0, 1, 2]) + ), } ) @@ -29,6 +34,7 @@ async def async_attach_trigger(hass, config, action, automation_info): topic = config[CONF_TOPIC] payload = config.get(CONF_PAYLOAD) encoding = config[CONF_ENCODING] or None + qos = config[CONF_QOS] @callback def mqtt_automation_listener(mqttmsg): @@ -49,6 +55,6 @@ async def async_attach_trigger(hass, config, action, automation_info): hass.async_run_job(action, {"trigger": data}) remove = await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener, encoding=encoding + hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos ) return remove diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json index 60db56146fa..32e1cc2fd40 100644 --- a/homeassistant/components/axis/.translations/no.json +++ b/homeassistant/components/axis/.translations/no.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten er allerede konfigurert", - "bad_config_file": "D\u00e5rlig data fra konfigurasjonsfilen", + "bad_config_file": "D\u00e5rlige data fra konfigurasjonsfilen", "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke", "not_axis_device": "Oppdaget enhet ikke en Axis enhet", "updated_configuration": "Oppdatert enhetskonfigurasjonen med ny vertsadresse" diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json index 95c7e23f50e..59761c7202f 100644 --- a/homeassistant/components/axis/.translations/sv.json +++ b/homeassistant/components/axis/.translations/sv.json @@ -2,7 +2,7 @@ "config": { "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", - "bad_config_file": "Felaktig data fr\u00e5n config fil", + "bad_config_file": "Felaktig data fr\u00e5n konfigurationsfilen", "link_local_address": "Link local addresses are not supported", "not_axis_device": "Uppt\u00e4ckte enhet som inte \u00e4r en Axis enhet", "updated_configuration": "Uppdaterad enhetskonfiguration med ny v\u00e4rdadress" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 9e507a212b9..6b6311c6b0d 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.7.0"], + "requirements": ["bimmer_connected==0.7.1"], "dependencies": [], "codeowners": ["@gerard33"] } diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index b3d9e00bfe6..756636ad90a 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Allow deCONZ CLIP sensors", "allow_deconz_groups": "Allow deCONZ light groups" }, - "description": "Configure visibility of deCONZ device types" + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" } } } diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 6a528a66ba6..2514a49f23c 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -37,6 +37,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) + and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzBinarySensor(sensor, gateway)) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 7b0f44807ec..34cc0e0b832 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -44,6 +44,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) + and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzThermostat(sensor, gateway)) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 615fd3db473..b3dedf6cf00 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -77,6 +77,11 @@ class DeconzDevice(DeconzBase, Entity): self.hass, self.gateway.signal_reachable, self.async_update_callback ) ) + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_remove_entity, self.async_remove_self + ) + ) async def async_will_remove_from_hass(self) -> None: """Disconnect device object when removed.""" @@ -85,6 +90,15 @@ class DeconzDevice(DeconzBase, Entity): for unsub_dispatcher in self.listeners: unsub_dispatcher() + async def async_remove_self(self, deconz_ids: list) -> None: + """Schedule removal of this entity. + + Called by signal_remove_entity scheduled by async_added_to_hass. + """ + if self._device.deconz_id not in deconz_ids: + return + await self.async_remove() + @callback def async_update_callback(self, force_update=False, ignore_update=False): """Update the device's state.""" diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 48fecc1ec4f..0b69b82463c 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -20,6 +20,8 @@ from .const import ( DOMAIN, LOGGER, NEW_DEVICE, + NEW_GROUP, + NEW_SENSOR, SUPPORTED_PLATFORMS, ) from .errors import AuthenticationRequired, CannotConnect @@ -45,6 +47,9 @@ class DeconzGateway: self.events = [] self.listeners = [] + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + @property def bridgeid(self) -> str: """Return the unique identifier of the gateway.""" @@ -108,22 +113,64 @@ class DeconzGateway: self.api.start() - self.config_entry.add_update_listener(self.async_new_address) + self.config_entry.add_update_listener(self.async_config_entry_updated) return True @staticmethod - async def async_new_address(hass, entry) -> None: - """Handle signals of gateway getting new address. + async def async_config_entry_updated(hass, entry) -> None: + """Handle signals of config entry being updated. - This is a static method because a class method (bound method), - can not be used with weak references. + This is a static method because a class method (bound method), can not be used with weak references. + Causes for this is either discovery updating host address or config entry options changing. """ gateway = get_gateway_from_config_entry(hass, entry) if gateway.api.host != entry.data[CONF_HOST]: gateway.api.close() gateway.api.host = entry.data[CONF_HOST] gateway.api.start() + return + + await gateway.options_updated() + + async def options_updated(self): + """Manage entities affected by config entry options.""" + deconz_ids = [] + + if self._current_option_allow_clip_sensor != self.option_allow_clip_sensor: + self._current_option_allow_clip_sensor = self.option_allow_clip_sensor + + sensors = [ + sensor + for sensor in self.api.sensors.values() + if sensor.type.startswith("CLIP") + ] + + if self.option_allow_clip_sensor: + self.async_add_device_callback(NEW_SENSOR, sensors) + else: + deconz_ids += [sensor.deconz_id for sensor in sensors] + + if self._current_option_allow_deconz_groups != self.option_allow_deconz_groups: + self._current_option_allow_deconz_groups = self.option_allow_deconz_groups + + groups = list(self.api.groups.values()) + + if self.option_allow_deconz_groups: + self.async_add_device_callback(NEW_GROUP, groups) + else: + deconz_ids += [group.deconz_id for group in groups] + + if deconz_ids: + async_dispatcher_send(self.hass, self.signal_remove_entity, deconz_ids) + + entity_registry = await self.hass.helpers.entity_registry.async_get_registry() + + for entity_id, deconz_id in self.deconz_ids.items(): + if deconz_id in deconz_ids and entity_registry.async_is_registered( + entity_id + ): + entity_registry.async_remove(entity_id) @property def signal_reachable(self) -> str: @@ -141,6 +188,11 @@ class DeconzGateway: """Gateway specific event to signal new device.""" return NEW_DEVICE[device_type].format(self.bridgeid) + @property + def signal_remove_entity(self) -> str: + """Gateway specific event to signal removal of entity.""" + return f"deconz-remove-{self.bridgeid}" + @callback def async_add_device_callback(self, device_type, device) -> None: """Handle event of new device creation in deCONZ.""" diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index e836f1e4490..f62f9315c49 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for group in groups: - if group.lights: + if group.lights and group.deconz_id not in gateway.deconz_ids.values(): entities.append(DeconzGroup(group, gateway)) async_add_entities(entities, True) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index c32b26f299d..6b88c414243 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -68,6 +68,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) + and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzSensor(sensor, gateway)) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index b61ea6236da..52cd90e54a1 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -14,20 +14,9 @@ "title": "Link with deCONZ", "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button" }, - "options": { - "title": "Extra configuration options for deCONZ", - "data": { - "allow_clip_sensor": "Allow importing virtual sensors", - "allow_deconz_groups": "Allow importing deCONZ groups" - } - }, "hassio_confirm": { "title": "deCONZ Zigbee gateway via Hass.io add-on", - "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the Hass.io add-on {addon}?", - "data": { - "allow_clip_sensor": "Allow importing virtual sensors", - "allow_deconz_groups": "Allow importing deCONZ groups" - } + "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the Hass.io add-on {addon}?" } }, "error": { @@ -45,11 +34,12 @@ "options": { "step": { "deconz_devices": { - "description": "Configure visibility of deCONZ device types", "data": { "allow_clip_sensor": "Allow deCONZ CLIP sensors", "allow_deconz_groups": "Allow deCONZ light groups" - } + }, + "description": "Configure visibility of deCONZ device types", + "title": "deCONZ options" } } }, @@ -105,4 +95,4 @@ "side_6": "Side 6" } } -} +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ca.json b/homeassistant/components/demo/.translations/ca.json index 944d358e739..a29718fea7a 100644 --- a/homeassistant/components/demo/.translations/ca.json +++ b/homeassistant/components/demo/.translations/ca.json @@ -1,5 +1,22 @@ { "config": { "title": "Demostraci\u00f3" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Entrada booleana opcional", + "int": "Entrada num\u00e8rica" + } + }, + "options_2": { + "data": { + "multi": "Selecci\u00f3 m\u00faltiple", + "select": "Selecciona una opci\u00f3", + "string": "Valor de cadena (string)" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/en.json b/homeassistant/components/demo/.translations/en.json index ef01fcb4f3c..e49671c88c8 100644 --- a/homeassistant/components/demo/.translations/en.json +++ b/homeassistant/components/demo/.translations/en.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Optional boolean", + "int": "Numeric input" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Select an option", + "string": "String value" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/it.json b/homeassistant/components/demo/.translations/it.json index ef01fcb4f3c..7b299913c8e 100644 --- a/homeassistant/components/demo/.translations/it.json +++ b/homeassistant/components/demo/.translations/it.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "uno", + "other": "altro" + } + }, + "options_1": { + "data": { + "bool": "Valore booleano facoltativo", + "int": "Input numerico" + } + }, + "options_2": { + "data": { + "multi": "Selezione multipla", + "select": "Selezionare un'opzione", + "string": "Valore stringa" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/nl.json b/homeassistant/components/demo/.translations/nl.json index ef01fcb4f3c..cb932a0d9d6 100644 --- a/homeassistant/components/demo/.translations/nl.json +++ b/homeassistant/components/demo/.translations/nl.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "Empty", + "other": "" + } + }, + "options_1": { + "data": { + "bool": "Optioneel Boolean", + "int": "Numerieke invoer" + } + }, + "options_2": { + "data": { + "multi": "Meerkeuze selectie", + "select": "Kies een optie", + "string": "String waarde" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/sl.json b/homeassistant/components/demo/.translations/sl.json index ef01fcb4f3c..b67d4d56fb1 100644 --- a/homeassistant/components/demo/.translations/sl.json +++ b/homeassistant/components/demo/.translations/sl.json @@ -1,5 +1,30 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "few": "prazni", + "one": "prazen", + "other": "prazni", + "two": "prazna" + } + }, + "options_1": { + "data": { + "bool": "Izbirna logi\u010dna vrednost", + "int": "\u0160tevil\u010dni vnos" + } + }, + "options_2": { + "data": { + "multi": "Multiselect", + "select": "Izberite mo\u017enost", + "string": "Vrednost niza" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/sv.json b/homeassistant/components/demo/.translations/sv.json index ef01fcb4f3c..4c5f477cc1c 100644 --- a/homeassistant/components/demo/.translations/sv.json +++ b/homeassistant/components/demo/.translations/sv.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "Tom", + "other": "Tomma" + } + }, + "options_1": { + "data": { + "bool": "Valfritt boolesk", + "int": "Numerisk inmatning" + } + }, + "options_2": { + "data": { + "multi": "Flera val", + "select": "V\u00e4lj ett alternativ", + "string": "Str\u00e4ngv\u00e4rde" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/zh-Hant.json b/homeassistant/components/demo/.translations/zh-Hant.json index cfb0fced0c2..7f6ac42d609 100644 --- a/homeassistant/components/demo/.translations/zh-Hant.json +++ b/homeassistant/components/demo/.translations/zh-Hant.json @@ -1,5 +1,22 @@ { "config": { "title": "\u5c55\u793a" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u9078\u9805\u5e03\u6797", + "int": "\u6578\u503c\u8f38\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9078\u64c7", + "select": "\u9078\u64c7\u9078\u9805", + "string": "\u5b57\u4e32\u503c" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json index 5a29a619a33..7a66490c90d 100644 --- a/homeassistant/components/freebox/manifest.json +++ b/homeassistant/components/freebox/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/freebox", "requirements": ["aiofreepybox==0.0.8"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@snoof85"] } diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6521901d692..a6f531b6dd5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -508,6 +508,23 @@ def websocket_get_themes(hass, connection, msg): Async friendly. """ + if hass.config.safe_mode: + connection.send_message( + websocket_api.result_message( + msg["id"], + { + "themes": { + "safe_mode": { + "primary-color": "#db4437", + "accent-color": "#eeee02", + } + }, + "default_theme": "safe_mode", + }, + ) + ) + return + connection.send_message( websocket_api.result_message( msg["id"], diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c2ffb203801..d35801d9177 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200217.0" + "home-assistant-frontend==20200219.0" ], "dependencies": [ "api", diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 775ca8760bc..50813b062f0 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -3,7 +3,7 @@ "name": "GeoNet NZ Quakes", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", - "requirements": ["aio_geojson_geonetnz_quakes==0.11"], + "requirements": ["aio_geojson_geonetnz_quakes==0.12"], "dependencies": [], "codeowners": ["@exxamalte"] } diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json index db8b8b035e0..a2816fa92f0 100644 --- a/homeassistant/components/homekit_controller/.translations/no.json +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -6,7 +6,7 @@ "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.", "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", - "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", + "invalid_config_entry": "Denne enheten vises som klar til sammenkobling, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Hjelpeassistenten som f\u00f8rst m\u00e5 fjernes.", "no_devices": "Ingen ukoblede enheter ble funnet" }, "error": { diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index 0d2131f9cb3..768c893a100 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -7,6 +7,7 @@ from homematicip.aio.device import ( AsyncFullFlushShutter, AsyncGarageDoorModuleTormatic, ) +from homematicip.aio.group import AsyncExtendedLinkedShutterGroup from homematicip.base.enums import DoorCommand, DoorState from homeassistant.components.cover import ( @@ -18,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import HomeAssistantType from . import DOMAIN as HMIPC_DOMAIN, HomematicipGenericDevice +from .hap import HomematicipHAP _LOGGER = logging.getLogger(__name__) @@ -41,6 +43,10 @@ async def async_setup_entry( elif isinstance(device, AsyncGarageDoorModuleTormatic): entities.append(HomematicipGarageDoorModuleTormatic(hap, device)) + for group in hap.home.groups: + if isinstance(group, AsyncExtendedLinkedShutterGroup): + entities.append(HomematicipCoverShutterGroup(hap, group)) + if entities: async_add_entities(entities) @@ -142,3 +148,12 @@ class HomematicipGarageDoorModuleTormatic(HomematicipGenericDevice, CoverDevice) async def async_stop_cover(self, **kwargs) -> None: """Stop the cover.""" await self._device.send_door_command(DoorCommand.STOP) + + +class HomematicipCoverShutterGroup(HomematicipCoverSlats, CoverDevice): + """Representation of a HomematicIP Cloud cover shutter group.""" + + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: + """Initialize switching group.""" + device.modelType = f"HmIP-{post}" + super().__init__(hap, device, post) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index d1c3b987a73..d6a226a83dc 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -14,6 +14,7 @@ from homematicip.aio.device import ( AsyncPassageDetector, AsyncPlugableSwitchMeasuring, AsyncPresenceDetectorIndoor, + AsyncRoomControlDeviceAnalog, AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorOutdoor, AsyncTemperatureHumiditySensorWithoutDisplay, @@ -79,6 +80,8 @@ async def async_setup_entry( ): entities.append(HomematicipTemperatureSensor(hap, device)) entities.append(HomematicipHumiditySensor(hap, device)) + elif isinstance(device, (AsyncRoomControlDeviceAnalog,)): + entities.append(HomematicipTemperatureSensor(hap, device)) if isinstance( device, ( diff --git a/homeassistant/components/ipma/.translations/lb.json b/homeassistant/components/ipma/.translations/lb.json index c9eb3a01941..7d8280998fe 100644 --- a/homeassistant/components/ipma/.translations/lb.json +++ b/homeassistant/components/ipma/.translations/lb.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breedegrad", "longitude": "L\u00e4ngegrad", + "mode": "Modus", "name": "Numm" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/nl.json b/homeassistant/components/ipma/.translations/nl.json index bc10eb3573e..00b9881fd97 100644 --- a/homeassistant/components/ipma/.translations/nl.json +++ b/homeassistant/components/ipma/.translations/nl.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitude", "longitude": "Longitude", + "mode": "Mode", "name": "Naam" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/sl.json b/homeassistant/components/ipma/.translations/sl.json index da6a1dac859..2dcfcde7404 100644 --- a/homeassistant/components/ipma/.translations/sl.json +++ b/homeassistant/components/ipma/.translations/sl.json @@ -8,6 +8,7 @@ "data": { "latitude": "Zemljepisna \u0161irina", "longitude": "Zemljepisna dol\u017eina", + "mode": "Na\u010din", "name": "Ime" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/ipma/.translations/sv.json b/homeassistant/components/ipma/.translations/sv.json index 4bdba6f0d08..e8cba56a0a0 100644 --- a/homeassistant/components/ipma/.translations/sv.json +++ b/homeassistant/components/ipma/.translations/sv.json @@ -8,6 +8,7 @@ "data": { "latitude": "Latitud", "longitude": "Longitud", + "mode": "L\u00e4ge", "name": "Namn" }, "description": "Portugisiska institutet f\u00f6r hav och atmosf\u00e4ren", diff --git a/homeassistant/components/konnected/.translations/it.json b/homeassistant/components/konnected/.translations/it.json index ab9aab266db..fb18ece10f8 100644 --- a/homeassistant/components/konnected/.translations/it.json +++ b/homeassistant/components/konnected/.translations/it.json @@ -29,6 +29,10 @@ "abort": { "not_konn_panel": "Non \u00e8 un dispositivo Konnected.io riconosciuto" }, + "error": { + "one": "uno", + "other": "altro" + }, "step": { "options_binary": { "data": { diff --git a/homeassistant/components/konnected/.translations/lb.json b/homeassistant/components/konnected/.translations/lb.json index 4f368f940a8..2e37ecb8e92 100644 --- a/homeassistant/components/konnected/.translations/lb.json +++ b/homeassistant/components/konnected/.translations/lb.json @@ -10,7 +10,16 @@ "cannot_connect": "Kann sech net mam Konnected Panel um {host}:{port} verbannen" }, "step": { + "confirm": { + "description": "Modell: {model}\nHost: {host}\nPort: {port}\n\nDir k\u00ebnnt den I/O a Panel Verhaalen an de Konnected Alarm Panel Astellunge konfigur\u00e9ieren.", + "title": "Konnected Apparat parat" + }, "user": { + "data": { + "host": "Konnected Apparat IP Adress", + "port": "Konnected Apparat Port" + }, + "description": "Informatioune vum Konnected Panel aginn.", "title": "Konnected Apparat entdecken" } }, @@ -20,6 +29,10 @@ "abort": { "not_konn_panel": "Keen erkannten Konnected.io Apparat" }, + "error": { + "one": "Ee", + "other": "M\u00e9i" + }, "step": { "options_binary": { "data": { @@ -27,13 +40,16 @@ "name": "Numm (optional)", "type": "Typ vun Bin\u00e4re Sensor" }, + "description": "Wiel d'Optioune fir den bin\u00e4ren Sensor dee mat {zone} verbonnen ass", "title": "Bin\u00e4re Sensor konfigur\u00e9ieren" }, "options_digital": { "data": { "name": "Numm (optional)", + "poll_interval": "Intervall vun den Offroen (Minutten) (optional)", "type": "Typ vum Sensor" }, + "description": "Wiel d'Optioune fir den digitale Sensor dee mat {zone} verbonnen ass", "title": "Digitale Sensor konfigur\u00e9ieren" }, "options_io": { @@ -47,6 +63,7 @@ "7": "Zon 7", "out": "OUT" }, + "description": "{model} um {host} entdeckt.\u00a0Wiel Basis Konfiguratioun vun den I/O hei dr\u00ebnner aus - ofh\u00e4ngeg vum I/O erlaabt et bin\u00e4r Sensoren (op / zou Kontakter), digital Sensoren (dht an ds18b20) oder schaltbar Ausgab. D\u00e9i detaill\u00e9iert Optioune k\u00ebnnen en an den n\u00e4chste Schr\u00ebtt konfigur\u00e9iert ginn.", "title": "I/O konfigur\u00e9ieren" }, "options_io_ext": { @@ -59,14 +76,26 @@ "alarm1": "ALARM1", "alarm2_out2": "OUT2/ALARM2", "out1": "OUT1" - } + }, + "description": "Wiel d'Konfiguratioun vun de verbleiwenden I/O hei dr\u00ebnner. D\u00e9i detaill\u00e9iert Optioune k\u00ebnnen en an den n\u00e4chste Schr\u00ebtt konfigur\u00e9iert ginn.", + "title": "Erweiderten I/O konfigur\u00e9ieren" + }, + "options_misc": { + "data": { + "blink": "Blink panel LED un wann Status \u00c4nnerung gesch\u00e9ckt g\u00ebtt" + }, + "description": "Wielt w.e.g. dat gew\u00ebnschte Verhalen fir \u00c4re Panel aus", + "title": "Divers Optioune astellen" }, "options_switch": { "data": { "activation": "Ausgang wann un", "momentary": "Pulsatiounsdauer (ms) (optional)", - "name": "Numm (optional)" + "name": "Numm (optional)", + "pause": "Pausen zw\u00ebscht den Impulser (ms) (optional)", + "repeat": "Unzuel vu Widderhuelungen (-1= onendlech) (optional)" }, + "description": "Wielt w.e.g. d'Ausgaboptiounen fir {zone}", "title": "\u00cbmschltbaren Ausgang konfigur\u00e9ieren" } }, diff --git a/homeassistant/components/konnected/.translations/nl.json b/homeassistant/components/konnected/.translations/nl.json index 8ccf6361853..1b6242b37f4 100644 --- a/homeassistant/components/konnected/.translations/nl.json +++ b/homeassistant/components/konnected/.translations/nl.json @@ -1,14 +1,28 @@ { "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "unknown": "Onbekende fout opgetreden" + }, "step": { + "confirm": { + "title": "Konnected Apparaat Klaar" + }, "user": { "data": { - "host": "IP-adres van Konnected apparaat" - } + "host": "IP-adres van Konnected apparaat", + "port": "Konnected apparaat poort" + }, + "description": "Voer de host-informatie in voor uw Konnected-paneel.", + "title": "Ontdek Konnected Device" } - } + }, + "title": "Konnected.io" }, "options": { + "abort": { + "not_konn_panel": "Geen herkend Konnected.io apparaat" + }, "error": { "one": "Leeg", "other": "Leeg" @@ -16,8 +30,11 @@ "step": { "options_binary": { "data": { - "name": "Naam (optioneel)" - } + "inverse": "Keer de open / dicht status om", + "name": "Naam (optioneel)", + "type": "Type binaire sensor" + }, + "title": "Binaire sensor configureren" }, "options_digital": { "data": { @@ -48,8 +65,10 @@ "options_switch": { "data": { "name": "Naam (optioneel)" - } + }, + "title": "Schakelbare uitgang configureren" } - } + }, + "title": "Konnected Alarm Paneel Opties" } } \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/pl.json b/homeassistant/components/konnected/.translations/pl.json index d3e6240af49..c2f992116a8 100644 --- a/homeassistant/components/konnected/.translations/pl.json +++ b/homeassistant/components/konnected/.translations/pl.json @@ -2,8 +2,27 @@ "config": { "abort": { "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.", + "already_in_progress": "Konfiguracja urz\u0105dzenia jest ju\u017c w toku.", + "not_konn_panel": "Nie rozpoznano urz\u0105dzenia Konnected.io", "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d." }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z panelem Konnected na {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model} \nHost: {host} \nPort: {port} \n\nMo\u017cesz skonfigurowa\u0107 IO i zachowanie panelu w ustawieniach Konnected Alarm Panel.", + "title": "Urz\u0105dzenie Konnected gotowe" + }, + "user": { + "data": { + "host": "Adres IP urz\u0105dzenia Konnected", + "port": "Port urz\u0105dzenia Konnected urz\u0105dzenia" + }, + "description": "Wprowad\u017a informacje o ho\u015bcie panelu Konnected.", + "title": "Wykryj urz\u0105dzenie Konnected" + } + }, "title": "Konnected.io" }, "options": { diff --git a/homeassistant/components/konnected/.translations/sv.json b/homeassistant/components/konnected/.translations/sv.json index abd15786aff..7e035264215 100644 --- a/homeassistant/components/konnected/.translations/sv.json +++ b/homeassistant/components/konnected/.translations/sv.json @@ -31,7 +31,7 @@ }, "error": { "one": "Tom", - "other": "Tom" + "other": "Tomma" }, "step": { "options_binary": { diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 6c99356907f..fc8cb67894b 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -89,6 +89,9 @@ class LovelaceStorage: async def async_load(self, force): """Load config.""" + if self._hass.config.safe_mode: + raise ConfigNotFound + if self._data is None: await self._load() diff --git a/homeassistant/components/melcloud/.translations/lb.json b/homeassistant/components/melcloud/.translations/lb.json index 7d94fbb53f8..b082ef78965 100644 --- a/homeassistant/components/melcloud/.translations/lb.json +++ b/homeassistant/components/melcloud/.translations/lb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "MELCloud Integratioun ass scho konfigur\u00e9iert fir d\u00ebs Email. Acc\u00e8s Jeton gouf erneiert." + }, "error": { "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", "invalid_auth": "Ong\u00eblteg Authentifikatioun", @@ -8,8 +11,10 @@ "step": { "user": { "data": { - "password": "MELCloud Passwuert" + "password": "MELCloud Passwuert", + "username": "Email d\u00e9i benotz g\u00ebtt fir sech mat MELCloud ze verbannen" }, + "description": "Verbann dech mat dengem MElCloud Kont.", "title": "Mat MELCloud verbannen" } }, diff --git a/homeassistant/components/melcloud/.translations/nl.json b/homeassistant/components/melcloud/.translations/nl.json new file mode 100644 index 00000000000..b60495e7f47 --- /dev/null +++ b/homeassistant/components/melcloud/.translations/nl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "MELCloud integratie is al geconfigureerd voor deze e-mail. Toegangstoken is vernieuwd." + }, + "error": { + "cannot_connect": "Verbinding mislukt, probeer het opnieuw", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "password": "MELCloud wachtwoord.", + "username": "E-mail gebruikt om in te loggen op MELCloud." + }, + "description": "Maak verbinding via uw MELCloud account.", + "title": "Maak verbinding met MELCloud" + } + }, + "title": "MELCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/melcloud/.translations/pl.json b/homeassistant/components/melcloud/.translations/pl.json index e996cf92d2e..9abb68ca85a 100644 --- a/homeassistant/components/melcloud/.translations/pl.json +++ b/homeassistant/components/melcloud/.translations/pl.json @@ -8,6 +8,16 @@ "invalid_auth": "Niepoprawne uwierzytelnienie.", "unknown": "Niespodziewany b\u0142\u0105d." }, + "step": { + "user": { + "data": { + "password": "Has\u0142o MELCloud.", + "username": "Adres e-mail u\u017cywany do logowania do MELCloud" + }, + "description": "Po\u0142\u0105cz u\u017cywaj\u0105c swojego konta MELCloud.", + "title": "Po\u0142\u0105cz si\u0119 z MELCloud" + } + }, "title": "MELCloud" } } \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/lb.json b/homeassistant/components/minecraft_server/.translations/lb.json index ddb3daf8523..f95dd062005 100644 --- a/homeassistant/components/minecraft_server/.translations/lb.json +++ b/homeassistant/components/minecraft_server/.translations/lb.json @@ -3,13 +3,20 @@ "abort": { "already_configured": "Apparat ass scho konfigur\u00e9iert" }, + "error": { + "cannot_connect": "Feeler beim verbannen mam Server. Iwwerpr\u00e9if den Numm a Port a prob\u00e9ier nach emol. G\u00e9i och s\u00e9cher dass op d'mannst Minecraft Versioun 1.7 um Server leeft.", + "invalid_ip": "IP Adress ass ong\u00eblteg (MAC Adress konnt net best\u00ebmmt ginn). Korrig\u00e9iert et a prob\u00e9iert et nach eng K\u00e9ier w.e.g.", + "invalid_port": "Port muss zw\u00ebscht 1024 a 65535 sinn. Korrig\u00e9iert et a prob\u00e9iert et nach eng K\u00e9ier w.e.g." + }, "step": { "user": { "data": { "host": "Apparat", "name": "Numm", "port": "Port" - } + }, + "description": "Riicht deng Minecraft Server Instanz a fir d'Iwwerwaachung z'erlaben", + "title": "Verbann d\u00e4in Minecraft Server" } }, "title": "Minecraft Server" diff --git a/homeassistant/components/minecraft_server/.translations/nl.json b/homeassistant/components/minecraft_server/.translations/nl.json index ba7d39baa10..75e19bc2550 100644 --- a/homeassistant/components/minecraft_server/.translations/nl.json +++ b/homeassistant/components/minecraft_server/.translations/nl.json @@ -4,7 +4,9 @@ "already_configured": "Host is al geconfigureerd." }, "error": { - "cannot_connect": "Kan geen verbinding maken met de server. Controleer de host en de poort en probeer het opnieuw. Zorg er ook voor dat u minimaal Minecraft versie 1.7 op uw server uitvoert." + "cannot_connect": "Kan geen verbinding maken met de server. Controleer de host en de poort en probeer het opnieuw. Zorg er ook voor dat u minimaal Minecraft versie 1.7 op uw server uitvoert.", + "invalid_ip": "IP-adres is ongeldig (MAC-adres kon niet worden bepaald). Corrigeer het en probeer het opnieuw.", + "invalid_port": "Poort moet tussen 1024 en 65535 liggen. Corrigeer dit en probeer het opnieuw." }, "step": { "user": { @@ -12,8 +14,11 @@ "host": "Host", "name": "Naam", "port": "Poort" - } + }, + "description": "Stel uw Minecraft server in om monitoring toe te staan.", + "title": "Koppel uw Minecraft server" } - } + }, + "title": "Minecraft server" } } \ No newline at end of file diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index db525e23935..218d3d3baa9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -159,10 +159,10 @@ def setup(hass, config): def write_register(service): """Write Modbus registers.""" - unit = int(float(service.data.get(ATTR_UNIT))) - address = int(float(service.data.get(ATTR_ADDRESS))) - value = service.data.get(ATTR_VALUE) - client_name = service.data.get(ATTR_HUB) + unit = int(float(service.data[ATTR_UNIT])) + address = int(float(service.data[ATTR_ADDRESS])) + value = service.data[ATTR_VALUE] + client_name = service.data[ATTR_HUB] if isinstance(value, list): hub_collect[client_name].write_registers( unit, address, [int(float(i)) for i in value] @@ -172,10 +172,10 @@ def setup(hass, config): def write_coil(service): """Write Modbus coil.""" - unit = service.data.get(ATTR_UNIT) - address = service.data.get(ATTR_ADDRESS) - state = service.data.get(ATTR_STATE) - client_name = service.data.get(ATTR_HUB) + unit = service.data[ATTR_UNIT] + address = service.data[ATTR_ADDRESS] + state = service.data[ATTR_STATE] + client_name = service.data[ATTR_HUB] hub_collect[client_name].write_coil(unit, address, state) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 02638c2a786..8ea6e2dbfa6 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -25,8 +25,8 @@ CONF_INPUTS = "inputs" CONF_INPUT_TYPE = "input_type" CONF_ADDRESS = "address" -INPUT_TYPE_COIL = "coil" -INPUT_TYPE_DISCRETE = "discrete_input" +DEFAULT_INPUT_TYPE_COIL = "coil" +DEFAULT_INPUT_TYPE_DISCRETE = "discrete_input" PLATFORM_SCHEMA = vol.All( cv.deprecated(CONF_DEPRECATED_COILS, CONF_INPUTS), @@ -43,8 +43,10 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional( - CONF_INPUT_TYPE, default=INPUT_TYPE_COIL - ): vol.In([INPUT_TYPE_COIL, INPUT_TYPE_DISCRETE]), + CONF_INPUT_TYPE, default=DEFAULT_INPUT_TYPE_COIL + ): vol.In( + [DEFAULT_INPUT_TYPE_COIL, DEFAULT_INPUT_TYPE_DISCRETE] + ), } ), ) @@ -57,16 +59,16 @@ PLATFORM_SCHEMA = vol.All( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus binary sensors.""" sensors = [] - for entry in config.get(CONF_INPUTS): - hub = hass.data[MODBUS_DOMAIN][entry.get(CONF_HUB)] + for entry in config[CONF_INPUTS]: + hub = hass.data[MODBUS_DOMAIN][entry[CONF_HUB]] sensors.append( ModbusBinarySensor( hub, - entry.get(CONF_NAME), + entry[CONF_NAME], entry.get(CONF_SLAVE), - entry.get(CONF_ADDRESS), + entry[CONF_ADDRESS], entry.get(CONF_DEVICE_CLASS), - entry.get(CONF_INPUT_TYPE), + entry[CONF_INPUT_TYPE], ) ) @@ -110,7 +112,7 @@ class ModbusBinarySensor(BinarySensorDevice): def update(self): """Update the state of the sensor.""" try: - if self._input_type == INPUT_TYPE_COIL: + if self._input_type == DEFAULT_INPUT_TYPE_COIL: result = self._hub.read_coils(self._slave, self._address, 1) else: result = self._hub.read_discrete_inputs(self._slave, self._address, 1) diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 18d928e9057..c0423849418 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -27,6 +27,7 @@ _LOGGER = logging.getLogger(__name__) CONF_TARGET_TEMP = "target_temp_register" CONF_CURRENT_TEMP = "current_temp_register" +CONF_CURRENT_TEMP_REGISTER_TYPE = "current_temp_register_type" CONF_DATA_TYPE = "data_type" CONF_COUNT = "data_count" CONF_PRECISION = "precision" @@ -42,6 +43,9 @@ CONF_STEP = "temp_step" SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE HVAC_MODES = [HVAC_MODE_AUTO] +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_CURRENT_TEMP): cv.positive_int, @@ -49,6 +53,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SLAVE): cv.positive_int, vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_COUNT, default=2): cv.positive_int, + vol.Optional( + CONF_CURRENT_TEMP_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING + ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT): vol.In( [DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT] ), @@ -66,20 +73,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Modbus Thermostat Platform.""" - name = config.get(CONF_NAME) - modbus_slave = config.get(CONF_SLAVE) - target_temp_register = config.get(CONF_TARGET_TEMP) - current_temp_register = config.get(CONF_CURRENT_TEMP) - data_type = config.get(CONF_DATA_TYPE) - count = config.get(CONF_COUNT) - precision = config.get(CONF_PRECISION) - scale = config.get(CONF_SCALE) - offset = config.get(CONF_OFFSET) - unit = config.get(CONF_UNIT) - max_temp = config.get(CONF_MAX_TEMP) - min_temp = config.get(CONF_MIN_TEMP) - temp_step = config.get(CONF_STEP) - hub_name = config.get(CONF_HUB) + name = config[CONF_NAME] + modbus_slave = config[CONF_SLAVE] + target_temp_register = config[CONF_TARGET_TEMP] + current_temp_register = config[CONF_CURRENT_TEMP] + current_temp_register_type = config[CONF_CURRENT_TEMP_REGISTER_TYPE] + data_type = config[CONF_DATA_TYPE] + count = config[CONF_COUNT] + precision = config[CONF_PRECISION] + scale = config[CONF_SCALE] + offset = config[CONF_OFFSET] + unit = config[CONF_UNIT] + max_temp = config[CONF_MAX_TEMP] + min_temp = config[CONF_MIN_TEMP] + temp_step = config[CONF_STEP] + hub_name = config[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] add_entities( @@ -90,6 +98,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): modbus_slave, target_temp_register, current_temp_register, + current_temp_register_type, data_type, count, precision, @@ -115,6 +124,7 @@ class ModbusThermostat(ClimateDevice): modbus_slave, target_temp_register, current_temp_register, + current_temp_register_type, data_type, count, precision, @@ -131,6 +141,7 @@ class ModbusThermostat(ClimateDevice): self._slave = modbus_slave self._target_temperature_register = target_temp_register self._current_temperature_register = current_temp_register + self._current_temperature_register_type = current_temp_register_type self._target_temperature = None self._current_temperature = None self._data_type = data_type @@ -161,10 +172,10 @@ class ModbusThermostat(ClimateDevice): def update(self): """Update Target & Current Temperature.""" self._target_temperature = self._read_register( - self._target_temperature_register + DEFAULT_REGISTER_TYPE_HOLDING, self._target_temperature_register ) self._current_temperature = self._read_register( - self._current_temperature_register + self._current_temperature_register_type, self._current_temperature_register ) @property @@ -228,12 +239,17 @@ class ModbusThermostat(ClimateDevice): """Return True if entity is available.""" return self._available - def _read_register(self, register) -> Optional[float]: - """Read holding register using the Modbus hub slave.""" + def _read_register(self, register_type, register) -> Optional[float]: + """Read register using the Modbus hub slave.""" try: - result = self._hub.read_holding_registers( - self._slave, register, self._count - ) + if register_type == DEFAULT_REGISTER_TYPE_INPUT: + result = self._hub.read_input_registers( + self._slave, register, self._count + ) + else: + result = self._hub.read_holding_registers( + self._slave, register, self._count + ) except ConnectionException: self._set_unavailable(register) return diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 3ffbe6d8c40..716cb5299b7 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -37,8 +37,8 @@ DATA_TYPE_FLOAT = "float" DATA_TYPE_INT = "int" DATA_TYPE_UINT = "uint" -REGISTER_TYPE_HOLDING = "holding" -REGISTER_TYPE_INPUT = "input" +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" def number(value: Any) -> Union[int, float]: @@ -74,9 +74,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_OFFSET, default=0): number, vol.Optional(CONF_PRECISION, default=0): cv.positive_int, - vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In( - [REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT] - ), + vol.Optional( + CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING + ): vol.In([DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT]), vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean, vol.Optional(CONF_SCALE, default=1): number, vol.Optional(CONF_SLAVE): cv.positive_int, @@ -95,17 +95,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data_types[DATA_TYPE_UINT] = {1: "H", 2: "I", 4: "Q"} data_types[DATA_TYPE_FLOAT] = {1: "e", 2: "f", 4: "d"} - for register in config.get(CONF_REGISTERS): + for register in config[CONF_REGISTERS]: structure = ">i" - if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM: + if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: try: structure = ">{}".format( - data_types[register.get(CONF_DATA_TYPE)][register.get(CONF_COUNT)] + data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]] ) except KeyError: _LOGGER.error( "Unable to detect data type for %s sensor, try a custom type", - register.get(CONF_NAME), + register[CONF_NAME], ) continue else: @@ -114,35 +114,33 @@ def setup_platform(hass, config, add_entities, discovery_info=None): try: size = struct.calcsize(structure) except struct.error as err: - _LOGGER.error( - "Error in sensor %s structure: %s", register.get(CONF_NAME), err - ) + _LOGGER.error("Error in sensor %s structure: %s", register[CONF_NAME], err) continue - if register.get(CONF_COUNT) * 2 != size: + if register[CONF_COUNT] * 2 != size: _LOGGER.error( "Structure size (%d bytes) mismatch registers count (%d words)", size, - register.get(CONF_COUNT), + register[CONF_COUNT], ) continue - hub_name = register.get(CONF_HUB) + hub_name = register[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] sensors.append( ModbusRegisterSensor( hub, - register.get(CONF_NAME), + register[CONF_NAME], register.get(CONF_SLAVE), - register.get(CONF_REGISTER), - register.get(CONF_REGISTER_TYPE), + register[CONF_REGISTER], + register[CONF_REGISTER_TYPE], register.get(CONF_UNIT_OF_MEASUREMENT), - register.get(CONF_COUNT), - register.get(CONF_REVERSE_ORDER), - register.get(CONF_SCALE), - register.get(CONF_OFFSET), + register[CONF_COUNT], + register[CONF_REVERSE_ORDER], + register[CONF_SCALE], + register[CONF_OFFSET], structure, - register.get(CONF_PRECISION), + register[CONF_PRECISION], register.get(CONF_DEVICE_CLASS), ) ) @@ -223,7 +221,7 @@ class ModbusRegisterSensor(RestoreEntity): def update(self): """Update the state of the sensor.""" try: - if self._register_type == REGISTER_TYPE_INPUT: + if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: result = self._hub.read_input_registers( self._slave, self._register, self._count ) diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py index 8c1e3b53834..d4f52622538 100644 --- a/homeassistant/components/modbus/switch.py +++ b/homeassistant/components/modbus/switch.py @@ -32,8 +32,8 @@ CONF_STATE_ON = "state_on" CONF_VERIFY_REGISTER = "verify_register" CONF_VERIFY_STATE = "verify_state" -REGISTER_TYPE_HOLDING = "holding" -REGISTER_TYPE_INPUT = "input" +DEFAULT_REGISTER_TYPE_HOLDING = "holding" +DEFAULT_REGISTER_TYPE_INPUT = "input" REGISTERS_SCHEMA = vol.Schema( { @@ -42,8 +42,8 @@ REGISTERS_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_REGISTER): cv.positive_int, vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string, - vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING): vol.In( - [REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT] + vol.Optional(CONF_REGISTER_TYPE, default=DEFAULT_REGISTER_TYPE_HOLDING): vol.In( + [DEFAULT_REGISTER_TYPE_HOLDING, DEFAULT_REGISTER_TYPE_INPUT] ), vol.Optional(CONF_SLAVE): cv.positive_int, vol.Optional(CONF_STATE_OFF): cv.positive_int, @@ -77,30 +77,30 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Read configuration and create Modbus devices.""" switches = [] if CONF_COILS in config: - for coil in config.get(CONF_COILS): - hub_name = coil.get(CONF_HUB) + for coil in config[CONF_COILS]: + hub_name = coil[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] switches.append( ModbusCoilSwitch( - hub, coil.get(CONF_NAME), coil.get(CONF_SLAVE), coil.get(CONF_COIL) + hub, coil[CONF_NAME], coil[CONF_SLAVE], coil[CONF_COIL] ) ) if CONF_REGISTERS in config: - for register in config.get(CONF_REGISTERS): - hub_name = register.get(CONF_HUB) + for register in config[CONF_REGISTERS]: + hub_name = register[CONF_HUB] hub = hass.data[MODBUS_DOMAIN][hub_name] switches.append( ModbusRegisterSwitch( hub, - register.get(CONF_NAME), + register[CONF_NAME], register.get(CONF_SLAVE), - register.get(CONF_REGISTER), - register.get(CONF_COMMAND_ON), - register.get(CONF_COMMAND_OFF), - register.get(CONF_VERIFY_STATE), + register[CONF_REGISTER], + register[CONF_COMMAND_ON], + register[CONF_COMMAND_OFF], + register[CONF_VERIFY_STATE], register.get(CONF_VERIFY_REGISTER), - register.get(CONF_REGISTER_TYPE), + register[CONF_REGISTER_TYPE], register.get(CONF_STATE_ON), register.get(CONF_STATE_OFF), ) @@ -242,7 +242,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): """Set switch on.""" # Only holding register is writable - if self._register_type == REGISTER_TYPE_HOLDING: + if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: self._write_register(self._command_on) if not self._verify_state: self._is_on = True @@ -251,7 +251,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): """Set switch off.""" # Only holding register is writable - if self._register_type == REGISTER_TYPE_HOLDING: + if self._register_type == DEFAULT_REGISTER_TYPE_HOLDING: self._write_register(self._command_off) if not self._verify_state: self._is_on = False @@ -282,7 +282,7 @@ class ModbusRegisterSwitch(ModbusCoilSwitch): def _read_register(self) -> Optional[int]: try: - if self._register_type == REGISTER_TYPE_INPUT: + if self._register_type == DEFAULT_REGISTER_TYPE_INPUT: result = self._hub.read_input_registers(self._slave, self._register, 1) else: result = self._hub.read_holding_registers( diff --git a/homeassistant/components/mqtt/.translations/en.json b/homeassistant/components/mqtt/.translations/en.json index ad18951a9d7..55baf3b7f0e 100644 --- a/homeassistant/components/mqtt/.translations/en.json +++ b/homeassistant/components/mqtt/.translations/en.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" double clicked", + "button_long_press": "\"{subtype}\" continuously pressed", + "button_long_release": "\"{subtype}\" released after long press", + "button_quadruple_press": "\"{subtype}\" quadruple clicked", + "button_quintuple_press": "\"{subtype}\" quintuple clicked", + "button_short_press": "\"{subtype}\" pressed", + "button_short_release": "\"{subtype}\" released", + "button_triple_press": "\"{subtype}\" triple clicked" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6780a33c7d7..540d09d7c9f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1194,6 +1194,34 @@ class MqttDiscoveryUpdate(Entity): ) +def device_info_from_config(config): + """Return a device description for device registry.""" + if not config: + return None + + info = { + "identifiers": {(DOMAIN, id_) for id_ in config[CONF_IDENTIFIERS]}, + "connections": {tuple(x) for x in config[CONF_CONNECTIONS]}, + } + + if CONF_MANUFACTURER in config: + info["manufacturer"] = config[CONF_MANUFACTURER] + + if CONF_MODEL in config: + info["model"] = config[CONF_MODEL] + + if CONF_NAME in config: + info["name"] = config[CONF_NAME] + + if CONF_SW_VERSION in config: + info["sw_version"] = config[CONF_SW_VERSION] + + if CONF_VIA_DEVICE in config: + info["via_device"] = (DOMAIN, config[CONF_VIA_DEVICE]) + + return info + + class MqttEntityDeviceInfo(Entity): """Mixin used for mqtt platforms that support the device registry.""" @@ -1216,32 +1244,7 @@ class MqttEntityDeviceInfo(Entity): @property def device_info(self): """Return a device description for device registry.""" - if not self._device_config: - return None - - info = { - "identifiers": { - (DOMAIN, id_) for id_ in self._device_config[CONF_IDENTIFIERS] - }, - "connections": {tuple(x) for x in self._device_config[CONF_CONNECTIONS]}, - } - - if CONF_MANUFACTURER in self._device_config: - info["manufacturer"] = self._device_config[CONF_MANUFACTURER] - - if CONF_MODEL in self._device_config: - info["model"] = self._device_config[CONF_MODEL] - - if CONF_NAME in self._device_config: - info["name"] = self._device_config[CONF_NAME] - - if CONF_SW_VERSION in self._device_config: - info["sw_version"] = self._device_config[CONF_SW_VERSION] - - if CONF_VIA_DEVICE in self._device_config: - info["via_device"] = (DOMAIN, self._device_config[CONF_VIA_DEVICE]) - - return info + return device_info_from_config(self._device_config) @websocket_api.async_response diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index acbc2731846..6cfab66c3f1 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -3,6 +3,7 @@ ABBREVIATIONS = { "act_t": "action_topic", "act_tpl": "action_template", + "atype": "automation_type", "aux_cmd_t": "aux_command_topic", "aux_stat_tpl": "aux_state_template", "aux_stat_t": "aux_state_topic", @@ -80,6 +81,7 @@ ABBREVIATIONS = { "osc_cmd_t": "oscillation_command_topic", "osc_stat_t": "oscillation_state_topic", "osc_val_tpl": "oscillation_value_template", + "pl": "payload", "pl_arm_away": "payload_arm_away", "pl_arm_home": "payload_arm_home", "pl_arm_nite": "payload_arm_night", @@ -142,6 +144,7 @@ ABBREVIATIONS = { "stat_t": "state_topic", "stat_tpl": "state_template", "stat_val_tpl": "state_value_template", + "stype": "subtype", "sup_feat": "supported_features", "swing_mode_cmd_t": "swing_mode_command_topic", "swing_mode_stat_tpl": "swing_mode_state_template", diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 4f2f29f94fb..885343b7090 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -178,15 +178,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( config, async_add_entities, config_entry, discovery_hash ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_hash) raise async_dispatcher_connect( diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py new file mode 100644 index 00000000000..3f0889875d0 --- /dev/null +++ b/homeassistant/components/mqtt/device_automation.py @@ -0,0 +1,44 @@ +"""Provides device automations for MQTT.""" +import logging + +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ATTR_DISCOVERY_HASH, device_trigger +from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +AUTOMATION_TYPE_TRIGGER = "trigger" +AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] +AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) +CONF_AUTOMATION_TYPE = "automation_type" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup_entry(hass, config_entry): + """Set up MQTT device automation dynamically through MQTT discovery.""" + + async def async_discover(discovery_payload): + """Discover and add an MQTT device automation.""" + discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + try: + config = PLATFORM_SCHEMA(discovery_payload) + if config[CONF_AUTOMATION_TYPE] == AUTOMATION_TYPE_TRIGGER: + await device_trigger.async_setup_trigger( + hass, config, config_entry, discovery_hash + ) + except Exception: + if discovery_hash: + clear_discovery_hash(hass, discovery_hash) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format("device_automation", "mqtt"), async_discover + ) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py new file mode 100644 index 00000000000..2149024266d --- /dev/null +++ b/homeassistant/components/mqtt/device_trigger.py @@ -0,0 +1,273 @@ +"""Provides device automations for MQTT.""" +import logging +from typing import List + +import attr +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.components.automation import AutomationActionType +import homeassistant.components.automation.mqtt as automation_mqtt +from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from . import ( + ATTR_DISCOVERY_HASH, + CONF_CONNECTIONS, + CONF_DEVICE, + CONF_IDENTIFIERS, + CONF_PAYLOAD, + CONF_QOS, + DOMAIN, +) +from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash + +_LOGGER = logging.getLogger(__name__) + +CONF_AUTOMATION_TYPE = "automation_type" +CONF_DISCOVERY_ID = "discovery_id" +CONF_SUBTYPE = "subtype" +CONF_TOPIC = "topic" +DEFAULT_ENCODING = "utf-8" +DEVICE = "device" + +MQTT_TRIGGER_BASE = { + # Trigger when MQTT message is received + CONF_PLATFORM: DEVICE, + CONF_DOMAIN: DOMAIN, +} + +TRIGGER_SCHEMA = TRIGGER_BASE_SCHEMA.extend( + { + vol.Required(CONF_PLATFORM): DEVICE, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DISCOVERY_ID): str, + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_SUBTYPE): cv.string, + } +) + +TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_AUTOMATION_TYPE): str, + vol.Required(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string), + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_SUBTYPE): cv.string, + }, + mqtt.validate_device_has_at_least_one_identifier, +) + +DEVICE_TRIGGERS = "mqtt_device_triggers" + + +@attr.s(slots=True) +class TriggerInstance: + """Attached trigger settings.""" + + action = attr.ib(type=AutomationActionType) + automation_info = attr.ib(type=dict) + trigger = attr.ib(type="Trigger") + remove = attr.ib(type=CALLBACK_TYPE, default=None) + + async def async_attach_trigger(self): + """Attach MQTT trigger.""" + mqtt_config = { + automation_mqtt.CONF_TOPIC: self.trigger.topic, + automation_mqtt.CONF_ENCODING: DEFAULT_ENCODING, + automation_mqtt.CONF_QOS: self.trigger.qos, + } + if self.trigger.payload: + mqtt_config[CONF_PAYLOAD] = self.trigger.payload + + if self.remove: + self.remove() + self.remove = await automation_mqtt.async_attach_trigger( + self.trigger.hass, mqtt_config, self.action, self.automation_info, + ) + + +@attr.s(slots=True) +class Trigger: + """Device trigger settings.""" + + device_id = attr.ib(type=str) + hass = attr.ib(type=HomeAssistantType) + payload = attr.ib(type=str) + qos = attr.ib(type=int) + subtype = attr.ib(type=str) + topic = attr.ib(type=str) + type = attr.ib(type=str) + trigger_instances = attr.ib(type=[TriggerInstance], default=attr.Factory(list)) + + async def add_trigger(self, action, automation_info): + """Add MQTT trigger.""" + instance = TriggerInstance(action, automation_info, self) + self.trigger_instances.append(instance) + + if self.topic is not None: + # If we know about the trigger, subscribe to MQTT topic + await instance.async_attach_trigger() + + @callback + def async_remove() -> None: + """Remove trigger.""" + if instance not in self.trigger_instances: + raise HomeAssistantError("Can't remove trigger twice") + + if instance.remove: + instance.remove() + self.trigger_instances.remove(instance) + + return async_remove + + async def update_trigger(self, config): + """Update MQTT device trigger.""" + self.type = config[CONF_TYPE] + self.subtype = config[CONF_SUBTYPE] + self.topic = config[CONF_TOPIC] + self.payload = config[CONF_PAYLOAD] + self.qos = config[CONF_QOS] + + # Unsubscribe+subscribe if this trigger is in use + for trig in self.trigger_instances: + await trig.async_attach_trigger() + + def detach_trigger(self): + """Remove MQTT device trigger.""" + # Mark trigger as unknown + + self.topic = None + # Unsubscribe if this trigger is in use + for trig in self.trigger_instances: + if trig.remove: + trig.remove() + trig.remove = None + + +async def _update_device(hass, config_entry, config): + """Update device registry.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + config_entry_id = config_entry.entry_id + device_info = mqtt.device_info_from_config(config[CONF_DEVICE]) + + if config_entry_id is not None and device_info is not None: + device_info["config_entry_id"] = config_entry_id + device_registry.async_get_or_create(**device_info) + + +async def async_setup_trigger(hass, config, config_entry, discovery_hash): + """Set up the MQTT device trigger.""" + config = TRIGGER_DISCOVERY_SCHEMA(config) + discovery_id = discovery_hash[1] + remove_signal = None + + async def discovery_update(payload): + """Handle discovery update.""" + _LOGGER.info( + "Got update for trigger with hash: %s '%s'", discovery_hash, payload + ) + if not payload: + # Empty payload: Remove trigger + _LOGGER.info("Removing trigger: %s", discovery_hash) + if discovery_id in hass.data[DEVICE_TRIGGERS]: + device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] + device_trigger.detach_trigger() + clear_discovery_hash(hass, discovery_hash) + remove_signal() + else: + # Non-empty payload: Update trigger + _LOGGER.info("Updating trigger: %s", discovery_hash) + payload.pop(ATTR_DISCOVERY_HASH) + config = TRIGGER_DISCOVERY_SCHEMA(payload) + await _update_device(hass, config_entry, config) + device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] + await device_trigger.update_trigger(config) + + remove_signal = async_dispatcher_connect( + hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_update + ) + + await _update_device(hass, config_entry, config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device( + {(DOMAIN, id_) for id_ in config[CONF_DEVICE][CONF_IDENTIFIERS]}, + {tuple(x) for x in config[CONF_DEVICE][CONF_CONNECTIONS]}, + ) + + if device is None: + return + + if DEVICE_TRIGGERS not in hass.data: + hass.data[DEVICE_TRIGGERS] = {} + if discovery_id not in hass.data[DEVICE_TRIGGERS]: + hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( + hass=hass, + device_id=device.id, + type=config[CONF_TYPE], + subtype=config[CONF_SUBTYPE], + topic=config[CONF_TOPIC], + payload=config[CONF_PAYLOAD], + qos=config[CONF_QOS], + ) + else: + await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(config) + + +async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device triggers for MQTT devices.""" + triggers = [] + + if DEVICE_TRIGGERS not in hass.data: + return triggers + + for discovery_id, trig in hass.data[DEVICE_TRIGGERS].items(): + if trig.device_id != device_id or trig.topic is None: + continue + + trigger = { + **MQTT_TRIGGER_BASE, + "device_id": device_id, + "type": trig.type, + "subtype": trig.subtype, + "discovery_id": discovery_id, + } + triggers.append(trigger) + + return triggers + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: dict, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + if DEVICE_TRIGGERS not in hass.data: + hass.data[DEVICE_TRIGGERS] = {} + config = TRIGGER_SCHEMA(config) + device_id = config[CONF_DEVICE_ID] + discovery_id = config[CONF_DISCOVERY_ID] + + if discovery_id not in hass.data[DEVICE_TRIGGERS]: + hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( + hass=hass, + device_id=device_id, + type=config[CONF_TYPE], + subtype=config[CONF_SUBTYPE], + topic=None, + payload=None, + qos=None, + ) + return await hass.data[DEVICE_TRIGGERS][discovery_id].add_trigger( + action, automation_info + ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index f393c315793..418f648564d 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -26,6 +26,7 @@ SUPPORTED_COMPONENTS = [ "camera", "climate", "cover", + "device_automation", "fan", "light", "lock", @@ -40,6 +41,7 @@ CONFIG_ENTRY_COMPONENTS = [ "camera", "climate", "cover", + "device_automation", "fan", "light", "lock", @@ -197,9 +199,15 @@ async def async_start( 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( - config_entry, component - ) + if component == "device_automation": + # Local import to avoid circular dependencies + from . import device_automation + + await device_automation.async_setup_entry(hass, config_entry) + else: + await hass.config_entries.async_forward_entry_setup( + config_entry, component + ) hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) async_dispatcher_send( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8bacfa530bd..f0a38bcbc55 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -27,5 +27,27 @@ "error": { "cannot_connect": "Unable to connect to the broker." } + }, + "device_automation": { + "trigger_type": { + "button_short_press": "\"{subtype}\" pressed", + "button_short_release": "\"{subtype}\" released", + "button_long_press": "\"{subtype}\" continuously pressed", + "button_long_release": "\"{subtype}\" released after long press", + "button_double_press": "\"{subtype}\" double clicked", + "button_triple_press": "\"{subtype}\" triple clicked", + "button_quadruple_press": "\"{subtype}\" quadruple clicked", + "button_quintuple_press": "\"{subtype}\" quintuple clicked" + }, + "trigger_subtype": { + "turn_on": "Turn on", + "turn_off": "Turn off", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "button_5": "Fifth button", + "button_6": "Sixth button" + } } } diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json index b2dcfe10cf6..1c2aa268ca2 100644 --- a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json +++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json @@ -2,7 +2,7 @@ "domain": "nsw_rural_fire_service_feed", "name": "NSW Rural Fire Service Incidents", "documentation": "https://www.home-assistant.io/integrations/nsw_rural_fire_service_feed", - "requirements": ["aio_geojson_nsw_rfs_incidents==0.1"], + "requirements": ["aio_geojson_nsw_rfs_incidents==0.3"], "dependencies": [], "codeowners": ["@exxamalte"] } diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index d63a543f227..98e7c320a60 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "requirements": [], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/plex/.translations/it.json b/homeassistant/components/plex/.translations/it.json index 1108b9e7e92..0cf7b943fd2 100644 --- a/homeassistant/components/plex/.translations/it.json +++ b/homeassistant/components/plex/.translations/it.json @@ -4,7 +4,7 @@ "all_configured": "Tutti i server collegati sono gi\u00e0 configurati", "already_configured": "Questo server Plex \u00e8 gi\u00e0 configurato", "already_in_progress": "Plex \u00e8 in fase di configurazione", - "discovery_no_file": "Nessun file di configurazione ereditario trovato", + "discovery_no_file": "Non \u00e8 stato trovato nessun file di configurazione da sostituire", "invalid_import": "La configurazione importata non \u00e8 valida", "non-interactive": "Importazione non interattiva", "token_request_timeout": "Timeout per l'ottenimento del token", diff --git a/homeassistant/components/plex/.translations/no.json b/homeassistant/components/plex/.translations/no.json index cc6dac8a35b..7d2b7cf2760 100644 --- a/homeassistant/components/plex/.translations/no.json +++ b/homeassistant/components/plex/.translations/no.json @@ -4,7 +4,7 @@ "all_configured": "Alle knyttet servere som allerede er konfigurert", "already_configured": "Denne Plex-serveren er allerede konfigurert", "already_in_progress": "Plex blir konfigurert", - "discovery_no_file": "Ingen eldre konfigurasjonsfil ble funnet", + "discovery_no_file": "Ingen eldre konfigurasjonsfil funnet", "invalid_import": "Den importerte konfigurasjonen er ugyldig", "non-interactive": "Ikke-interaktiv import", "token_request_timeout": "Tidsavbrudd ved innhenting av token", diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 89659769192..0f1873fc86f 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -27,6 +27,7 @@ from homeassistant.helpers.dispatcher import ( ) from .const import ( + CONF_IGNORE_NEW_SHARED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -50,6 +51,7 @@ MEDIA_PLAYER_SCHEMA = vol.Schema( { vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, + vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean, } ) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 0cbdd4679a9..19cec6dfb8b 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -14,12 +14,15 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.const import CONF_SSL, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json from .const import ( # pylint: disable=unused-import AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, CONF_SHOW_ALL_CONTROLS, @@ -28,6 +31,7 @@ from .const import ( # pylint: disable=unused-import DOMAIN, PLEX_CONFIG_FILE, PLEX_SERVER_CONFIG, + SERVERS, X_PLEX_DEVICE_NAME, X_PLEX_PLATFORM, X_PLEX_PRODUCT, @@ -254,6 +258,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): """Initialize Plex options flow.""" self.options = copy.deepcopy(config_entry.options) + self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input=None): """Manage the Plex options.""" @@ -261,6 +266,8 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_plex_mp_settings(self, user_input=None): """Manage the Plex media_player options.""" + plex_server = self.hass.data[DOMAIN][SERVERS][self.server_id] + if user_input is not None: self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ CONF_USE_EPISODE_ART @@ -268,19 +275,56 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] = user_input[ CONF_SHOW_ALL_CONTROLS ] + self.options[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = user_input[ + CONF_IGNORE_NEW_SHARED_USERS + ] + + account_data = { + user: {"enabled": bool(user in user_input[CONF_MONITORED_USERS])} + for user in plex_server.accounts + } + + self.options[MP_DOMAIN][CONF_MONITORED_USERS] = account_data + return self.async_create_entry(title="", data=self.options) + available_accounts = {name: name for name in plex_server.accounts} + available_accounts[plex_server.owner] += " [Owner]" + + default_accounts = plex_server.accounts + known_accounts = set(plex_server.option_monitored_users) + if known_accounts: + default_accounts = { + user + for user in plex_server.option_monitored_users + if plex_server.option_monitored_users[user]["enabled"] + } + for user in plex_server.accounts: + if user not in known_accounts: + available_accounts[user] += " [New]" + + if not plex_server.option_ignore_new_shared_users: + for new_user in plex_server.accounts - known_accounts: + default_accounts.add(new_user) + return self.async_show_form( step_id="plex_mp_settings", data_schema=vol.Schema( { vol.Required( CONF_USE_EPISODE_ART, - default=self.options[MP_DOMAIN][CONF_USE_EPISODE_ART], + default=plex_server.option_use_episode_art, ): bool, vol.Required( CONF_SHOW_ALL_CONTROLS, - default=self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS], + default=plex_server.option_show_all_controls, + ): bool, + vol.Optional( + CONF_MONITORED_USERS, default=default_accounts + ): cv.multi_select(available_accounts), + vol.Required( + CONF_IGNORE_NEW_SHARED_USERS, + default=plex_server.option_ignore_new_shared_users, ): bool, } ), diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 635c981b531..7d6812674ca 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -29,6 +29,8 @@ CONF_SERVER = "server" CONF_SERVER_IDENTIFIER = "server_id" CONF_USE_EPISODE_ART = "use_episode_art" CONF_SHOW_ALL_CONTROLS = "show_all_controls" +CONF_IGNORE_NEW_SHARED_USERS = "ignore_new_shared_users" +CONF_MONITORED_USERS = "monitored_users" AUTH_CALLBACK_PATH = "/auth/plex/callback" AUTH_CALLBACK_NAME = "auth:plex:callback" diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index cd94bb49632..47e5ba6104f 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -218,6 +218,7 @@ class PlexMediaPlayer(MediaPlayerDevice): if session_device: self._make = session_device.device or "" self._player_state = session_device.state + self._device_platform = self._device_platform or session_device.platform self._device_product = self._device_product or session_device.product self._device_title = self._device_title or session_device.title self._device_version = self._device_version or session_device.version @@ -243,7 +244,7 @@ class PlexMediaPlayer(MediaPlayerDevice): self._media_content_id = self.session.ratingKey self._media_content_rating = getattr(self.session, "contentRating", None) - name_parts = [self._device_product, self._device_title] + name_parts = [self._device_product, self._device_title or self._device_platform] if (self._device_product in COMMON_PLAYERS) and self.make: # Add more context in name for likely duplicates name_parts.append(self.make) @@ -274,7 +275,7 @@ class PlexMediaPlayer(MediaPlayerDevice): thumb_url = self.session.thumbUrl if ( self.media_content_type is MEDIA_TYPE_TVSHOW - and not self.plex_server.use_episode_art + and not self.plex_server.option_use_episode_art ): thumb_url = self.session.url(self.session.grandparentThumb) @@ -481,7 +482,7 @@ class PlexMediaPlayer(MediaPlayerDevice): def supported_features(self): """Flag media player features that are supported.""" # force show all controls - if self.plex_server.show_all_controls: + if self.plex_server.option_show_all_controls: return ( SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK @@ -738,8 +739,8 @@ class PlexMediaPlayer(MediaPlayerDevice): return { "identifiers": {(PLEX_DOMAIN, self.machine_identifier)}, - "manufacturer": "Plex", - "model": self._device_product or self._device_platform or self.make, + "manufacturer": self._device_platform or "Plex", + "model": self._device_product or self.make, "name": self.name, "sw_version": self._device_version, "via_device": (PLEX_DOMAIN, self.plex_server.machine_identifier), diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index fe453ef2e9e..5532362b87a 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -13,6 +13,8 @@ from homeassistant.helpers.dispatcher import dispatcher_send from .const import ( CONF_CLIENT_IDENTIFIER, + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, CONF_SERVER, CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, @@ -51,6 +53,7 @@ class PlexServer: self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) self.options = options self.server_choice = None + self._accounts = [] self._owner_username = None self._version = None @@ -95,6 +98,12 @@ class PlexServer: else: _connect_with_token() + self._accounts = [ + account.name + for account in self._plex_server.systemAccounts() + if account.name + ] + owner_account = [ account.name for account in self._plex_server.systemAccounts() @@ -121,8 +130,22 @@ class PlexServer: _LOGGER.debug("Updating devices") available_clients = {} + ignored_clients = set() new_clients = set() + monitored_users = self.accounts + known_accounts = set(self.option_monitored_users) + if known_accounts: + monitored_users = { + user + for user in self.option_monitored_users + if self.option_monitored_users[user]["enabled"] + } + + if not self.option_ignore_new_shared_users: + for new_user in self.accounts - known_accounts: + monitored_users.add(new_user) + try: devices = self._plex_server.clients() sessions = self._plex_server.sessions() @@ -147,7 +170,12 @@ class PlexServer: if session.TYPE == "photo": _LOGGER.debug("Photo session detected, skipping: %s", session) continue + session_username = session.usernames[0] for player in session.players: + if session_username not in monitored_users: + ignored_clients.add(player.machineIdentifier) + _LOGGER.debug("Ignoring Plex client owned by %s", session_username) + continue self._known_idle.discard(player.machineIdentifier) available_clients.setdefault( player.machineIdentifier, {"device": player} @@ -160,6 +188,8 @@ class PlexServer: new_entity_configs = [] for client_id, client_data in available_clients.items(): + if client_id in ignored_clients: + continue if client_id in new_clients: new_entity_configs.append(client_data) else: @@ -167,11 +197,11 @@ class PlexServer: client_id, client_data["device"], client_data.get("session") ) - self._known_clients.update(new_clients) + self._known_clients.update(new_clients | ignored_clients) - idle_clients = (self._known_clients - self._known_idle).difference( - available_clients - ) + idle_clients = ( + self._known_clients - self._known_idle - ignored_clients + ).difference(available_clients) for client_id in idle_clients: self.refresh_entity(client_id, None, None) self._known_idle.add(client_id) @@ -194,6 +224,11 @@ class PlexServer: """Return the plexapi PlexServer instance.""" return self._plex_server + @property + def accounts(self): + """Return accounts associated with the Plex server.""" + return set(self._accounts) + @property def owner(self): """Return the Plex server owner username.""" @@ -220,15 +255,25 @@ class PlexServer: return self._plex_server._baseurl # pylint: disable=protected-access @property - def use_episode_art(self): + def option_ignore_new_shared_users(self): + """Return ignore_new_shared_users option.""" + return self.options[MP_DOMAIN].get(CONF_IGNORE_NEW_SHARED_USERS, False) + + @property + def option_use_episode_art(self): """Return use_episode_art option.""" return self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] @property - def show_all_controls(self): + def option_show_all_controls(self): """Return show_all_controls option.""" return self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] + @property + def option_monitored_users(self): + """Return dict of monitored users option.""" + return self.options[MP_DOMAIN].get(CONF_MONITORED_USERS, {}) + @property def library(self): """Return library attribute from server object.""" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 39abbcf9c6f..1f99e28df8b 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -36,7 +36,9 @@ "description": "Options for Plex Media Players", "data": { "use_episode_art": "Use episode art", - "show_all_controls": "Show all controls" + "show_all_controls": "Show all controls", + "ignore_new_shared_users": "Ignore new managed/shared users", + "monitored_users": "Monitored users" } } } diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json index 8551b3da3e6..80c12cc746c 100644 --- a/homeassistant/components/ps4/manifest.json +++ b/homeassistant/components/ps4/manifest.json @@ -3,7 +3,7 @@ "name": "Sony PlayStation 4", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ps4", - "requirements": ["pyps4-2ndscreen==1.0.6"], + "requirements": ["pyps4-2ndscreen==1.0.7"], "dependencies": [], "codeowners": ["@ktnrg45"] } diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index b92a95af9d7..ba67f61b2ee 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/roku", "requirements": ["roku==4.0.0"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/rpi_gpio_pwm/light.py b/homeassistant/components/rpi_gpio_pwm/light.py index aededbc676c..96ac3c6f2ed 100644 --- a/homeassistant/components/rpi_gpio_pwm/light.py +++ b/homeassistant/components/rpi_gpio_pwm/light.py @@ -19,7 +19,7 @@ from homeassistant.components.light import ( SUPPORT_TRANSITION, Light, ) -from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE, STATE_ON +from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_TYPE, STATE_ON import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.color as color_util @@ -58,6 +58,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_TYPE): vol.In(CONF_LED_TYPES), vol.Optional(CONF_FREQUENCY): cv.positive_int, vol.Optional(CONF_ADDRESS): cv.byte, + vol.Optional(CONF_HOST): cv.string, } ], ) @@ -76,6 +77,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if CONF_FREQUENCY in led_conf: opt_args["freq"] = led_conf[CONF_FREQUENCY] if driver_type == CONF_DRIVER_GPIO: + if CONF_HOST in led_conf: + opt_args["host"] = led_conf[CONF_HOST] driver = GpioDriver(pins, **opt_args) elif driver_type == CONF_DRIVER_PCA9685: if CONF_ADDRESS in led_conf: diff --git a/homeassistant/components/rpi_gpio_pwm/manifest.json b/homeassistant/components/rpi_gpio_pwm/manifest.json index 688cad8324e..46fe96a6426 100644 --- a/homeassistant/components/rpi_gpio_pwm/manifest.json +++ b/homeassistant/components/rpi_gpio_pwm/manifest.json @@ -2,7 +2,7 @@ "domain": "rpi_gpio_pwm", "name": "pigpio Daemon PWM LED", "documentation": "https://www.home-assistant.io/integrations/rpi_gpio_pwm", - "requirements": ["pwmled==1.4.1"], + "requirements": ["pwmled==1.5.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json index 78cfd4aa1f0..6fec5c008b3 100644 --- a/homeassistant/components/sabnzbd/manifest.json +++ b/homeassistant/components/sabnzbd/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/sabnzbd", "requirements": ["pysabnzbd==1.1.0"], "dependencies": ["configurator"], + "after_dependencies": ["discovery"], "codeowners": [] } diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 8ae0b99f447..83ed7d22351 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -15,7 +15,13 @@ from simplipy.websocket import ( import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import ( + ATTR_CODE, + CONF_CODE, + CONF_PASSWORD, + CONF_TOKEN, + CONF_USERNAME, +) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -59,6 +65,7 @@ DATA_LISTENER = "listener" TOPIC_UPDATE = "simplisafe_update_data_{0}" EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" +EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" DEFAULT_SOCKET_MIN_RETRY = 15 DEFAULT_WATCHDOG_SECONDS = 5 * 60 @@ -71,6 +78,7 @@ WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT = [ EVENT_MOTION_DETECTED, ] +ATTR_CATEGORY = "category" ATTR_LAST_EVENT_CHANGED_BY = "last_event_changed_by" ATTR_LAST_EVENT_INFO = "last_event_info" ATTR_LAST_EVENT_SENSOR_NAME = "last_event_sensor_name" @@ -79,10 +87,12 @@ ATTR_LAST_EVENT_SENSOR_TYPE = "last_event_sensor_type" ATTR_LAST_EVENT_TIMESTAMP = "last_event_timestamp" ATTR_LAST_EVENT_TYPE = "last_event_type" ATTR_LAST_EVENT_TYPE = "last_event_type" +ATTR_MESSAGE = "message" ATTR_PIN_LABEL = "label" ATTR_PIN_LABEL_OR_VALUE = "label_or_pin" ATTR_PIN_VALUE = "pin" ATTR_SYSTEM_ID = "system_id" +ATTR_TIMESTAMP = "timestamp" SERVICE_BASE_SCHEMA = vol.Schema({vol.Required(ATTR_SYSTEM_ID): cv.positive_int}) @@ -428,10 +438,43 @@ class SimpliSafe: self._config_entry = config_entry self._emergency_refresh_token_used = False self._hass = hass + self._system_notifications = {} self.initial_event_to_use = {} - self.systems = None + self.systems = {} self.websocket = SimpliSafeWebsocket(hass, api.websocket) + @callback + def _async_process_new_notifications(self, system): + """Act on any new system notifications.""" + old_notifications = self._system_notifications.get(system.system_id, []) + latest_notifications = system.notifications + + # Save the latest notifications: + self._system_notifications[system.system_id] = latest_notifications + + # Process any notifications that are new: + to_add = set(latest_notifications) - set(old_notifications) + + if not to_add: + return + + _LOGGER.debug("New system notifications: %s", to_add) + + for notification in to_add: + text = notification.text + if notification.link: + text = f"{text} For more information: {notification.link}" + + self._hass.bus.async_fire( + EVENT_SIMPLISAFE_NOTIFICATION, + event_data={ + ATTR_CATEGORY: notification.category, + ATTR_CODE: notification.code, + ATTR_MESSAGE: text, + ATTR_TIMESTAMP: notification.timestamp, + }, + ) + async def async_init(self): """Initialize the data class.""" asyncio.create_task(self.websocket.async_websocket_connect()) @@ -471,6 +514,7 @@ class SimpliSafe: async def update_system(system): """Update a system.""" await system.update() + self._async_process_new_notifications(system) _LOGGER.debug('Updated REST API data for "%s"', system.address) async_dispatcher_send(self._hass, TOPIC_UPDATE.format(system.system_id)) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 19a9e20cd6b..232540ee47b 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -406,7 +406,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): self._device.device_id, mode, ) - self._hvac_modes = modes + self._hvac_modes = list(modes) @property def current_temperature(self): diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index f4276160d6c..1bf66810e5b 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,6 +6,7 @@ from twitch import TwitchClient import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -13,6 +14,13 @@ _LOGGER = logging.getLogger(__name__) ATTR_GAME = "game" ATTR_TITLE = "title" +ATTR_SUBSCRIPTION = "subscribed" +ATTR_SUBSCRIPTION_SINCE = "subscribed_since" +ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" +ATTR_FOLLOW = "following" +ATTR_FOLLOW_SINCE = "following_since" +ATTR_FOLLOWING = "followers" +ATTR_VIEWS = "views" CONF_CHANNELS = "channels" CONF_CLIENT_ID = "client_id" @@ -26,6 +34,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CHANNELS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_TOKEN): cv.string, } ) @@ -34,29 +43,35 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Twitch platform.""" channels = config[CONF_CHANNELS] client_id = config[CONF_CLIENT_ID] - client = TwitchClient(client_id=client_id) + oauth_token = config.get(CONF_TOKEN) + client = TwitchClient(client_id, oauth_token) try: client.ingests.get_server_list() except HTTPError: - _LOGGER.error("Client ID is not valid") + _LOGGER.error("Client ID or OAuth token is not valid") return - users = client.users.translate_usernames_to_ids(channels) + channel_ids = client.users.translate_usernames_to_ids(channels) - add_entities([TwitchSensor(user, client) for user in users], True) + add_entities([TwitchSensor(channel_id, client) for channel_id in channel_ids], True) class TwitchSensor(Entity): """Representation of an Twitch channel.""" - def __init__(self, user, client): + def __init__(self, channel, client): """Initialize the sensor.""" self._client = client - self._user = user - self._channel = self._user.name - self._id = self._user.id - self._state = self._preview = self._game = self._title = None + self._channel = channel + self._oauth_enabled = client._oauth_token is not None + self._state = None + self._preview = None + self._game = None + self._title = None + self._subscription = None + self._follow = None + self._statistics = None @property def should_poll(self): @@ -66,7 +81,7 @@ class TwitchSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return self._channel + return self._channel.display_name @property def state(self): @@ -81,28 +96,67 @@ class TwitchSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" + attr = { + ATTR_FRIENDLY_NAME: self._channel.display_name, + } + attr.update(self._statistics) + + if self._oauth_enabled: + attr.update(self._subscription) + attr.update(self._follow) + if self._state == STATE_STREAMING: - return {ATTR_GAME: self._game, ATTR_TITLE: self._title} + attr.update({ATTR_GAME: self._game, ATTR_TITLE: self._title}) + return attr @property def unique_id(self): """Return unique ID for this sensor.""" - return self._id + return self._channel.id @property def icon(self): """Icon to use in the frontend, if any.""" return ICON - # pylint: disable=no-member def update(self): """Update device state.""" - stream = self._client.streams.get_stream_by_user(self._id) + + channel = self._client.channels.get_by_id(self._channel.id) + + self._statistics = { + ATTR_FOLLOWING: channel.followers, + ATTR_VIEWS: channel.views, + } + if self._oauth_enabled: + user = self._client.users.get() + + try: + sub = self._client.users.check_subscribed_to_channel( + user.id, self._channel.id + ) + self._subscription = { + ATTR_SUBSCRIPTION: True, + ATTR_SUBSCRIPTION_SINCE: sub.created_at, + ATTR_SUBSCRIPTION_GIFTED: sub.is_gift, + } + except HTTPError: + self._subscription = {ATTR_SUBSCRIPTION: False} + + try: + follow = self._client.users.check_follows_channel( + user.id, self._channel.id + ) + self._follow = {ATTR_FOLLOW: True, ATTR_FOLLOW_SINCE: follow.created_at} + except HTTPError: + self._follow = {ATTR_FOLLOW: False} + + stream = self._client.streams.get_stream_by_user(self._channel.id) if stream: - self._game = stream.get("channel").get("game") - self._title = stream.get("channel").get("status") - self._preview = stream.get("preview").get("medium") + self._game = stream.channel.get("game") + self._title = stream.channel.get("status") + self._preview = stream.preview.get("medium") self._state = STATE_STREAMING else: - self._preview = self._client.users.get_by_id(self._id).get("logo") + self._preview = self._channel.logo self._state = STATE_OFFLINE diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index d9b65b6d1da..f1f96b3c363 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", + "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" - } + }, + "description": "Configure device tracking", + "title": "UniFi options" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients" - } + "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" + }, + "description": "Configure statistics sensors", + "title": "UniFi options" } } } diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json index 9041f018423..3448c291325 100644 --- a/homeassistant/components/unifi/.translations/no.json +++ b/homeassistant/components/unifi/.translations/no.json @@ -33,12 +33,6 @@ "track_wired_clients": "Inkluder kablede nettverksklienter" } }, - "init": { - "data": { - "one": "en", - "other": "andre" - } - }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Opprett b\u00e5ndbreddesensorer for nettverksklienter" diff --git a/homeassistant/components/unifi/.translations/sv.json b/homeassistant/components/unifi/.translations/sv.json index 23960999244..dbf5373aa9a 100644 --- a/homeassistant/components/unifi/.translations/sv.json +++ b/homeassistant/components/unifi/.translations/sv.json @@ -36,7 +36,7 @@ "init": { "data": { "one": "Tom", - "other": "Tom" + "other": "Tomma" } }, "statistics_sensors": { diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 9dbacc7916d..36fa7489e81 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -12,21 +12,18 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_CONTROLLER, CONF_DETECTION_TIME, CONF_SITE_ID, + CONF_SSID_FILTER, CONF_TRACK_CLIENTS, CONF_TRACK_DEVICES, CONF_TRACK_WIRED_CLIENTS, CONTROLLER_ID, - DEFAULT_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_DETECTION_TIME, - DEFAULT_TRACK_CLIENTS, - DEFAULT_TRACK_DEVICES, - DEFAULT_TRACK_WIRED_CLIENTS, DOMAIN, LOGGER, ) @@ -185,33 +182,30 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options.update(user_input) return await self.async_step_statistics_sensors() + controller = get_controller_from_config_entry(self.hass, self.config_entry) + + ssid_filter = {wlan: wlan for wlan in controller.api.wlans} + return self.async_show_form( step_id="device_tracker", data_schema=vol.Schema( { vol.Optional( - CONF_TRACK_CLIENTS, - default=self.config_entry.options.get( - CONF_TRACK_CLIENTS, DEFAULT_TRACK_CLIENTS - ), + CONF_TRACK_CLIENTS, default=controller.option_track_clients, ): bool, vol.Optional( CONF_TRACK_WIRED_CLIENTS, - default=self.config_entry.options.get( - CONF_TRACK_WIRED_CLIENTS, DEFAULT_TRACK_WIRED_CLIENTS - ), + default=controller.option_track_wired_clients, ): bool, vol.Optional( - CONF_TRACK_DEVICES, - default=self.config_entry.options.get( - CONF_TRACK_DEVICES, DEFAULT_TRACK_DEVICES - ), + CONF_TRACK_DEVICES, default=controller.option_track_devices, ): bool, + vol.Optional( + CONF_SSID_FILTER, default=controller.option_ssid_filter + ): cv.multi_select(ssid_filter), vol.Optional( CONF_DETECTION_TIME, - default=self.config_entry.options.get( - CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME - ), + default=int(controller.option_detection_time.total_seconds()), ): int, } ), @@ -223,16 +217,15 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options.update(user_input) return await self._update_options() + controller = get_controller_from_config_entry(self.hass, self.config_entry) + return self.async_show_form( step_id="statistics_sensors", data_schema=vol.Schema( { vol.Optional( CONF_ALLOW_BANDWIDTH_SENSORS, - default=self.config_entry.options.get( - CONF_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_ALLOW_BANDWIDTH_SENSORS, - ), + default=controller.option_allow_bandwidth_sensors, ): bool } ), diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index b4a4a5dab16..a42b136e665 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", "requirements": [ - "aiounifi==12" + "aiounifi==13" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index ce2f2345917..e652b60ee32 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -31,15 +31,20 @@ "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", + "ssid_filter": "Select SSIDs to track wireless clients on", "track_clients": "Track network clients", "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" - } + }, + "description": "Configure device tracking", + "title": "UniFi options" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients" - } + "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" + }, + "description": "Configure statistics sensors", + "title": "UniFi options" } } } diff --git a/homeassistant/components/vallox/manifest.json b/homeassistant/components/vallox/manifest.json index 875cc6f8787..7a082200740 100644 --- a/homeassistant/components/vallox/manifest.json +++ b/homeassistant/components/vallox/manifest.json @@ -2,7 +2,7 @@ "domain": "vallox", "name": "Valloxs", "documentation": "https://www.home-assistant.io/integrations/vallox", - "requirements": ["vallox-websocket-api==2.2.0"], + "requirements": ["vallox-websocket-api==2.4.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/vilfo/.translations/nl.json b/homeassistant/components/vilfo/.translations/nl.json index 5eb3a4a073e..db2691d3eeb 100644 --- a/homeassistant/components/vilfo/.translations/nl.json +++ b/homeassistant/components/vilfo/.translations/nl.json @@ -1,11 +1,21 @@ { "config": { + "abort": { + "already_configured": "Deze Vilfo Router is al geconfigureerd." + }, + "error": { + "cannot_connect": "Kon niet verbinden. Controleer de door u verstrekte informatie en probeer het opnieuw.", + "unknown": "Er is een onverwachte fout opgetreden tijdens het instellen van de integratie." + }, "step": { "user": { "data": { + "access_token": "Toegangstoken voor de Vilfo Router API", "host": "Router hostnaam of IP-adres" - } + }, + "title": "Maak verbinding met de Vilfo Router" } - } + }, + "title": "Vilfo Router" } } \ No newline at end of file diff --git a/homeassistant/components/vilfo/.translations/sl.json b/homeassistant/components/vilfo/.translations/sl.json new file mode 100644 index 00000000000..a7d683e793c --- /dev/null +++ b/homeassistant/components/vilfo/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Ta usmerjevalnik Vilfo je \u017ee konfiguriran." + }, + "error": { + "cannot_connect": "Povezava ni uspela. Prosimo, preverite informacije, ki ste jih vnesli in poskusite znova.", + "invalid_auth": "Neveljavna avtentikacija. Preverite dostopni \u017eeton in poskusite znova.", + "unknown": "Med nastavitvijo integracije je pri\u0161lo do nepri\u010dakovane napake." + }, + "step": { + "user": { + "data": { + "access_token": "Dostopni \u017eeton za API Vilfo Router", + "host": "Ime gostitelja usmerjevalnika ali IP" + }, + "description": "Nastavite integracijo Vilfo Router. Potrebujete ime gostitelja ali IP Vilfo usmerjevalnika in dostopni \u017eeton API. Za dodatne informacije o tej integraciji in kako do teh podrobnosti obi\u0161\u010dite: https://www.home-assistant.io/integrations/vilfo", + "title": "Pove\u017eite se z usmerjevalnikom Vilfo" + } + }, + "title": "Vilfo Router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json index be1cae7aaf1..fd81f0b7c3c 100644 --- a/homeassistant/components/vizio/.translations/no.json +++ b/homeassistant/components/vizio/.translations/no.json @@ -6,7 +6,7 @@ "already_setup_with_diff_host_and_name": "Denne oppf\u00f8ringen ser ut til \u00e5 allerede v\u00e6re konfigurert med en annen vert og navn basert p\u00e5 serienummeret. Fjern den gamle oppf\u00f8ringer fra konfigurasjonen.yaml og fra integrasjonsmenyen f\u00f8r du pr\u00f8ver ut \u00e5 legge til denne enheten p\u00e5 nytt.", "host_exists": "Vizio komponent med vert allerede konfigurert.", "name_exists": "Vizio-komponent med navn som allerede er konfigurert.", - "updated_entry": "Denne oppf\u00f8ringen er allerede konfigurert, men navnet og / eller alternativene som er definert i konfigurasjonen, stemmer ikke overens med den tidligere importerte konfigurasjonen, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter.", + "updated_entry": "Denne oppf\u00f8ringen er allerede konfigurert, men navnet og / eller alternativene som er definert i konfigurasjonen samsvarer ikke med den tidligere importerte konfigurasjonen, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter.", "updated_options": "Denne oppf\u00f8ringen er allerede konfigurert, men alternativene som er definert i konfigurasjonen samsvarer ikke med de tidligere importerte alternativverdiene, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter.", "updated_volume_step": "Denne oppf\u00f8ringen er allerede konfigurert, men volumstrinnst\u00f8rrelsen i konfigurasjonen samsvarer ikke med konfigurasjonsoppf\u00f8ringen, s\u00e5 konfigurasjonsoppf\u00f8ringen er oppdatert deretter." }, diff --git a/homeassistant/components/vizio/.translations/sv.json b/homeassistant/components/vizio/.translations/sv.json index 70d163e166f..072b441a071 100644 --- a/homeassistant/components/vizio/.translations/sv.json +++ b/homeassistant/components/vizio/.translations/sv.json @@ -6,7 +6,7 @@ "already_setup_with_diff_host_and_name": "Den h\u00e4r posten verkar redan ha st\u00e4llts in med en annan v\u00e4rd och ett annat namn baserat p\u00e5 dess serienummer. Ta bort alla gamla poster fr\u00e5n configuration.yaml och fr\u00e5n menyn Integrationer innan du f\u00f6rs\u00f6ker l\u00e4gga till den h\u00e4r enheten igen.", "host_exists": "Vizio-komponenten med v\u00e4rdnamnet \u00e4r redan konfigurerad.", "name_exists": "Vizio-komponent med namn redan konfigurerad.", - "updated_entry": "Den h\u00e4r posten har redan konfigurerats men namnet och/eller alternativen som definierats i konfigurationen matchar inte den tidigare importerade konfigurationen s\u00e5 konfigureringsposten har uppdaterats i enlighet med detta.", + "updated_entry": "Den h\u00e4r posten har redan konfigurerats, men namnet och/eller alternativen som definierats i konfigurationen matchar inte den tidigare importerade konfigurationen och d\u00e4rf\u00f6r har konfigureringsposten uppdaterats i enlighet med detta.", "updated_options": "Den h\u00e4r posten har redan st\u00e4llts in men de alternativ som definierats i konfigurationen matchar inte de tidigare importerade alternativv\u00e4rdena s\u00e5 konfigurationsposten har uppdaterats i enlighet med detta.", "updated_volume_step": "Den h\u00e4r posten har redan st\u00e4llts in men volymstegstorleken i konfigurationen matchar inte konfigurationsposten s\u00e5 konfigurationsposten har uppdaterats i enlighet med detta." }, diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json index 4568f67dbf5..fade5e1a51b 100644 --- a/homeassistant/components/xiaomi_aqara/manifest.json +++ b/homeassistant/components/xiaomi_aqara/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/xiaomi_aqara", "requirements": ["PyXiaomiGateway==0.12.4"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@danielhiversen", "@syssi"] } diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 35c2a8ddfac..1a181536d0b 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/yeelight", "requirements": ["yeelight==0.5.0"], "dependencies": [], + "after_dependencies": ["discovery"], "codeowners": ["@rytilahti", "@zewelor"] } diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b4dbbda51f1..206f529344f 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -108,7 +108,6 @@ def setup(hass, config): def stop_zeroconf(_): """Stop Zeroconf.""" - zeroconf.unregister_service(info) zeroconf.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf) diff --git a/homeassistant/core.py b/homeassistant/core.py index e819a32b7c7..c17c1f698ce 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1288,6 +1288,9 @@ class Config: # List of allowed external dirs to access self.whitelist_external_dirs: Set[str] = set() + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> Optional[float]: """Calculate distance from Home Assistant. @@ -1350,6 +1353,7 @@ class Config: "whitelist_external_dirs": self.whitelist_external_dirs, "version": __version__, "config_source": self.config_source, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 05b687a8454..5996fb6eaf7 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -392,11 +392,14 @@ class EntityRegistry: unique_id=entity["unique_id"], platform=entity["platform"], name=entity.get("name"), + icon=entity.get("icon"), disabled_by=entity.get("disabled_by"), capabilities=entity.get("capabilities") or {}, supported_features=entity.get("supported_features", 0), device_class=entity.get("device_class"), unit_of_measurement=entity.get("unit_of_measurement"), + original_name=entity.get("original_name"), + original_icon=entity.get("original_icon"), ) self.entities = entities @@ -419,11 +422,14 @@ class EntityRegistry: "unique_id": entry.unique_id, "platform": entry.platform, "name": entry.name, + "icon": entry.icon, "disabled_by": entry.disabled_by, "capabilities": entry.capabilities, "supported_features": entry.supported_features, "device_class": entry.device_class, "unit_of_measurement": entry.unit_of_measurement, + "original_name": entry.original_name, + "original_icon": entry.original_icon, } for entry in self.entities.values() ] diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9033202e652..4c46d437760 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -41,7 +41,6 @@ DATA_INTEGRATIONS = "integrations" DATA_CUSTOM_COMPONENTS = "custom_components" PACKAGE_CUSTOM_COMPONENTS = "custom_components" PACKAGE_BUILTIN = "homeassistant.components" -LOOKUP_PATHS = [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] CUSTOM_WARNING = ( "You are using a custom integration for %s which has not " "been tested by Home Assistant. This component might " @@ -67,6 +66,9 @@ async def _async_get_custom_components( hass: "HomeAssistant", ) -> Dict[str, "Integration"]: """Return list of custom integrations.""" + if hass.config.safe_mode: + return {} + try: import custom_components except ImportError: @@ -178,7 +180,7 @@ class Integration: Will create a stub manifest. """ - comp = _load_file(hass, domain, LOOKUP_PATHS) + comp = _load_file(hass, domain, _lookup_path(hass)) if comp is None: return None @@ -464,7 +466,7 @@ class Components: component: Optional[ModuleType] = integration.get_component() else: # Fallback to importing old-school - component = _load_file(self._hass, comp_name, LOOKUP_PATHS) + component = _load_file(self._hass, comp_name, _lookup_path(self._hass)) if component is None: raise ImportError(f"Unable to load {comp_name}") @@ -546,3 +548,10 @@ def _async_mount_config_dir(hass: "HomeAssistant") -> bool: if hass.config.config_dir not in sys.path: sys.path.insert(0, hass.config.config_dir) return True + + +def _lookup_path(hass: "HomeAssistant") -> List[str]: + """Return the lookup paths for legacy lookups.""" + if hass.config.safe_mode: + return [PACKAGE_BUILTIN] + return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 943a91fc3cc..1cfc92a6aab 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200217.0 +home-assistant-frontend==20200219.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 2d6221dd580..94dc816e03c 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -4,7 +4,7 @@ import json import logging import os import tempfile -from typing import Dict, List, Optional, Type, Union +from typing import Any, Dict, List, Optional, Type, Union from homeassistant.exceptions import HomeAssistantError @@ -85,7 +85,7 @@ def save_json( _LOGGER.error("JSON replacement cleanup failed: %s", err) -def find_paths_unserializable_data(bad_data: Union[List, Dict]) -> List[str]: +def find_paths_unserializable_data(bad_data: Any) -> List[str]: """Find the paths to unserializable data. This method is slow! Only use for error handling. @@ -98,9 +98,9 @@ def find_paths_unserializable_data(bad_data: Union[List, Dict]) -> List[str]: try: json.dumps(obj) - valid = True + continue except TypeError: - valid = False + pass if isinstance(obj, dict): for key, value in obj.items(): @@ -115,7 +115,7 @@ def find_paths_unserializable_data(bad_data: Union[List, Dict]) -> List[str]: elif isinstance(obj, list): for idx, value in enumerate(obj): to_process.append((value, f"{obj_path}[{idx}]")) - elif not valid: # type: ignore + else: invalid.append(obj_path) return invalid diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index de04f23d9dd..1a46a34c1a8 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -80,16 +80,19 @@ class AsyncHandler: def _process(self) -> None: """Process log in a thread.""" - while True: - record = asyncio.run_coroutine_threadsafe( - self._queue.get(), self.loop - ).result() + try: + while True: + record = asyncio.run_coroutine_threadsafe( + self._queue.get(), self.loop + ).result() - if record is None: - self.handler.close() - return + if record is None: + self.handler.close() + return - self.handler.emit(record) + self.handler.emit(record) + except asyncio.CancelledError: + self.handler.close() def createLock(self) -> None: """Ignore lock stuff.""" diff --git a/requirements_all.txt b/requirements_all.txt index 35d7710f5f0..b72a61f57f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -123,13 +123,13 @@ adguardhome==0.4.1 afsapi==0.0.4 # homeassistant.components.geonetnz_quakes -aio_geojson_geonetnz_quakes==0.11 +aio_geojson_geonetnz_quakes==0.12 # homeassistant.components.geonetnz_volcano aio_geojson_geonetnz_volcano==0.5 # homeassistant.components.nsw_rural_fire_service_feed -aio_geojson_nsw_rfs_incidents==0.1 +aio_geojson_nsw_rfs_incidents==0.3 # homeassistant.components.gdacs aio_georss_gdacs==0.3 @@ -199,7 +199,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==12 +aiounifi==13 # homeassistant.components.wwlln aiowwlln==2.0.2 @@ -305,7 +305,7 @@ beewi_smartclim==0.0.7 bellows-homeassistant==0.13.2 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.7.0 +bimmer_connected==0.7.1 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -683,7 +683,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200217.0 +home-assistant-frontend==20200219.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 @@ -1072,7 +1072,7 @@ pushetta==1.0.15 pushover_complete==1.1.1 # homeassistant.components.rpi_gpio_pwm -pwmled==1.4.1 +pwmled==1.5.0 # homeassistant.components.august py-august==0.14.0 @@ -1460,7 +1460,7 @@ pypjlink2==1.2.0 pypoint==1.1.2 # homeassistant.components.ps4 -pyps4-2ndscreen==1.0.6 +pyps4-2ndscreen==1.0.7 # homeassistant.components.qwikswitch pyqwikswitch==0.93 @@ -2025,7 +2025,7 @@ uscisstatus==0.1.1 uvcclient==0.11.0 # homeassistant.components.vallox -vallox-websocket-api==2.2.0 +vallox-websocket-api==2.4.0 # homeassistant.components.venstar venstarcolortouch==0.12 diff --git a/requirements_test.txt b/requirements_test.txt index bd2875f7115..db76d1ec46b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ asynctest==0.13.0 codecov==2.0.15 mock-open==1.3.1 mypy==0.761 -pre-commit==2.0.1 +pre-commit==2.1.0 pylint==2.4.4 astroid==2.3.3 pylint-strict-informational==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index def4c0e3d30..48863e32db9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -35,13 +35,13 @@ adb-shell==0.1.1 adguardhome==0.4.1 # homeassistant.components.geonetnz_quakes -aio_geojson_geonetnz_quakes==0.11 +aio_geojson_geonetnz_quakes==0.12 # homeassistant.components.geonetnz_volcano aio_geojson_geonetnz_volcano==0.5 # homeassistant.components.nsw_rural_fire_service_feed -aio_geojson_nsw_rfs_incidents==0.1 +aio_geojson_nsw_rfs_incidents==0.3 # homeassistant.components.gdacs aio_georss_gdacs==0.3 @@ -78,7 +78,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==12 +aiounifi==13 # homeassistant.components.wwlln aiowwlln==2.0.2 @@ -254,7 +254,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200217.0 +home-assistant-frontend==20200219.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 @@ -533,7 +533,7 @@ pyotp==2.3.0 pypoint==1.1.2 # homeassistant.components.ps4 -pyps4-2ndscreen==1.0.6 +pyps4-2ndscreen==1.0.7 # homeassistant.components.qwikswitch pyqwikswitch==0.93 @@ -574,6 +574,9 @@ python-miio==0.4.8 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.twitch +python-twitch-client==0.6.0 + # homeassistant.components.velbus python-velbus==2.0.41 diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 4b9dc21abb9..4d8fbb226f8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -103,8 +103,6 @@ ALLOWED_USED_COMPONENTS = { "homeassistant", "system_log", "person", - # Discovery - "discovery", # Other "mjpeg", # base class, has no reqs or component to load. "stream", # Stream cannot install on all systems, can be imported without reqs. diff --git a/script/run-in-env.sh b/script/run-in-env.sh index 586f59d717a..d9fe17f4b17 100755 --- a/script/run-in-env.sh +++ b/script/run-in-env.sh @@ -1,4 +1,4 @@ -#!/bin/sh -eu +#!/usr/bin/env sh -eu # Activate pyenv and virtualenv if present, then run the specified command diff --git a/script/translations_develop b/script/translations_develop index c3bf8d8e03f..f0976f3d676 100755 --- a/script/translations_develop +++ b/script/translations_develop @@ -58,7 +58,7 @@ def main(): if download_dir.is_dir(): rmtree(str(download_dir)) - download_dir.mkdir() + download_dir.mkdir(parents=True) subprocess.run("script/translations_upload_merge.py") diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 0266f6fb5bf..9be8f697b8b 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, PropertyMock from august.activity import Activity from august.api import Api from august.exceptions import AugustApiHTTPError -from august.lock import Lock +from august.lock import Lock, LockDetail from homeassistant.components.august import AugustData from homeassistant.components.august.binary_sensor import AugustDoorBinarySensor @@ -13,15 +13,7 @@ from homeassistant.components.august.lock import AugustLock from homeassistant.util import dt -class MockAugustApi(Api): - """A mock for py-august Api class.""" - - def _call_api(self, *args, **kwargs): - """Mock the time activity started.""" - raise AugustApiHTTPError("This should bubble up as its user consumable") - - -class MockAugustApiFailing(MockAugustApi): +class MockAugustApiFailing(Api): """A mock for py-august Api class that always has an AugustApiHTTPError.""" def _call_api(self, *args, **kwargs): @@ -94,7 +86,7 @@ class MockAugustComponentData(AugustData): self, last_lock_status_update_timestamp=1, last_door_state_update_timestamp=1, - api=MockAugustApi(), + api=MockAugustApiFailing(), access_token="mocked_access_token", locks=[], doorbells=[], @@ -158,8 +150,46 @@ def _mock_august_authentication(token_text, token_timestamp): return authentication -def _mock_august_lock(): - return Lock( - "mockdeviceid1", - {"LockName": "Mocked Lock 1", "HouseID": "mockhouseid1", "UserType": "owner"}, - ) +def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"): + return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid)) + + +def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): + return { + "_id": lockid, + "LockID": lockid, + "LockName": lockid + " Name", + "HouseID": houseid, + "UserType": "owner", + "SerialNumber": "mockserial", + "battery": 90, + "currentFirmwareVersion": "mockfirmware", + "Bridge": { + "_id": "bridgeid1", + "firmwareVersion": "mockfirm", + "operative": True, + }, + "LockStatus": {"doorState": "open"}, + } + + +def _mock_operative_august_lock_detail(lockid): + operative_lock_detail_data = _mock_august_lock_data(lockid=lockid) + return LockDetail(operative_lock_detail_data) + + +def _mock_inoperative_august_lock_detail(lockid): + inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid) + del inoperative_lock_detail_data["Bridge"] + return LockDetail(inoperative_lock_detail_data) + + +def _mock_doorsense_enabled_august_lock_detail(lockid): + doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) + return LockDetail(doorsense_lock_detail_data) + + +def _mock_doorsense_missing_august_lock_detail(lockid): + doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) + del doorsense_lock_detail_data["LockStatus"]["doorState"] + return LockDetail(doorsense_lock_detail_data) diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index cf2555e67a5..3a43a0a841a 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -2,6 +2,9 @@ import asyncio from unittest.mock import MagicMock +from august.lock import LockDetail +from requests import RequestException + from homeassistant.components import august from homeassistant.exceptions import HomeAssistantError @@ -11,6 +14,10 @@ from tests.components.august.mocks import ( _mock_august_authentication, _mock_august_authenticator, _mock_august_lock, + _mock_doorsense_enabled_august_lock_detail, + _mock_doorsense_missing_august_lock_detail, + _mock_inoperative_august_lock_detail, + _mock_operative_august_lock_detail, ) @@ -19,7 +26,7 @@ def test_get_lock_name(): data = MockAugustComponentData(last_lock_status_update_timestamp=1) lock = _mock_august_lock() data.set_mocked_locks([lock]) - assert data.get_lock_name("mockdeviceid1") == "Mocked Lock 1" + assert data.get_lock_name("mocklockid1") == "mocklockid1 Name" def test_unlock_throws_august_api_http_error(): @@ -29,11 +36,12 @@ def test_unlock_throws_august_api_http_error(): data.set_mocked_locks([lock]) last_err = None try: - data.unlock("mockdeviceid1") + data.unlock("mocklockid1") except HomeAssistantError as err: last_err = err assert ( - str(last_err) == "Mocked Lock 1: This should bubble up as its user consumable" + str(last_err) + == "mocklockid1 Name: This should bubble up as its user consumable" ) @@ -44,14 +52,51 @@ def test_lock_throws_august_api_http_error(): data.set_mocked_locks([lock]) last_err = None try: - data.unlock("mockdeviceid1") + data.unlock("mocklockid1") except HomeAssistantError as err: last_err = err assert ( - str(last_err) == "Mocked Lock 1: This should bubble up as its user consumable" + str(last_err) + == "mocklockid1 Name: This should bubble up as its user consumable" ) +def test_inoperative_locks_are_filtered_out(): + """Ensure inoperative locks do not get setup.""" + august_operative_lock = _mock_operative_august_lock_detail("oplockid1") + data = _create_august_data_with_lock_details( + [august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")] + ) + + assert len(data.locks) == 1 + assert data.locks[0].device_id == "oplockid1" + + +def test_lock_has_doorsense(): + """Check to see if a lock has doorsense.""" + data = _create_august_data_with_lock_details( + [ + _mock_doorsense_enabled_august_lock_detail("doorsenselock1"), + _mock_doorsense_missing_august_lock_detail("nodoorsenselock1"), + RequestException("mocked request error"), + RequestException("mocked request error"), + ] + ) + + assert data.lock_has_doorsense("doorsenselock1") is True + assert data.lock_has_doorsense("nodoorsenselock1") is False + + # The api calls are mocked to fail on the second + # run of async_get_lock_detail + # + # This will be switched to await data.async_get_lock_detail("doorsenselock1") + # once we mock the full home assistant setup + data._update_locks_detail() + # doorsenselock1 should be false if we cannot tell due + # to an api error + assert data.lock_has_doorsense("doorsenselock1") is False + + async def test__refresh_access_token(hass): """Test refresh of the access token.""" authentication = _mock_august_authentication("original_token", 1234) @@ -72,3 +117,21 @@ async def test__refresh_access_token(hass): authenticator.refresh_access_token.assert_called() assert data._access_token == "new_token" assert data._access_token_expires == 5678 + + +def _create_august_data_with_lock_details(lock_details): + locks = [] + for lock in lock_details: + if isinstance(lock, LockDetail): + locks.append(_mock_august_lock(lock.device_id)) + authentication = _mock_august_authentication("original_token", 1234) + authenticator = _mock_august_authenticator() + token_refresh_lock = MagicMock() + api = MagicMock() + api.get_lock_status = MagicMock(return_value=(MagicMock(), MagicMock())) + api.get_lock_detail = MagicMock(side_effect=lock_details) + api.get_operable_locks = MagicMock(return_value=locks) + api.get_doorbells = MagicMock(return_value=[]) + return august.AugustData( + MagicMock(), api, authentication, authenticator, token_refresh_lock + ) diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index d63a861fa28..8b036861899 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -21,10 +21,7 @@ from tests.components.august.mocks import ( def test__sync_lock_activity_locked_via_onetouchlock(): """Test _sync_lock_activity locking.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - august_lock = _mock_august_lock() - data.set_mocked_locks([august_lock]) - lock = MockAugustComponentLock(data, august_lock) + lock = _mocked_august_component_lock() lock_activity_start_timestamp = 1234 lock_activity = MockActivity( action=ACTION_LOCK_ONETOUCHLOCK, @@ -40,10 +37,7 @@ def test__sync_lock_activity_locked_via_onetouchlock(): def test__sync_lock_activity_locked_via_lock(): """Test _sync_lock_activity locking.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - august_lock = _mock_august_lock() - data.set_mocked_locks([august_lock]) - lock = MockAugustComponentLock(data, august_lock) + lock = _mocked_august_component_lock() lock_activity_start_timestamp = 1234 lock_activity = MockActivity( action=ACTION_LOCK_LOCK, @@ -59,10 +53,7 @@ def test__sync_lock_activity_locked_via_lock(): def test__sync_lock_activity_unlocked(): """Test _sync_lock_activity unlocking.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - august_lock = _mock_august_lock() - data.set_mocked_locks([august_lock]) - lock = MockAugustComponentLock(data, august_lock) + lock = _mocked_august_component_lock() lock_activity_timestamp = 1234 lock_activity = MockActivity( action=ACTION_LOCK_UNLOCK, @@ -110,3 +101,10 @@ def test__sync_lock_activity_ignores_old_data(): assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( datetime.datetime.fromtimestamp(first_lock_activity_timestamp) ) + + +def _mocked_august_component_lock(): + data = MockAugustComponentData(last_lock_status_update_timestamp=1) + august_lock = _mock_august_lock() + data.set_mocked_locks([august_lock]) + return MockAugustComponentLock(data, august_lock) diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index c190cd82649..864ba91fbc1 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -134,6 +134,28 @@ async def test_allow_clip_sensor(hass): vibration_sensor = hass.states.get("binary_sensor.vibration_sensor") assert vibration_sensor.state == "on" + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False} + ) + await hass.async_block_till_done() + + assert "binary_sensor.presence_sensor" in gateway.deconz_ids + assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids + assert "binary_sensor.clip_presence_sensor" not in gateway.deconz_ids + assert "binary_sensor.vibration_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 3 + + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True} + ) + await hass.async_block_till_done() + + assert "binary_sensor.presence_sensor" in gateway.deconz_ids + assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids + assert "binary_sensor.clip_presence_sensor" in gateway.deconz_ids + assert "binary_sensor.vibration_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 4 + async def test_add_new_binary_sensor(hass): """Test that adding a new binary sensor works.""" diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 802571cdf60..c03dc72019e 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -214,6 +214,30 @@ async def test_clip_climate_device(hass): clip_thermostat = hass.states.get("climate.clip_thermostat") assert clip_thermostat.state == "heat" + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False} + ) + await hass.async_block_till_done() + + assert "climate.thermostat" in gateway.deconz_ids + assert "sensor.thermostat" not in gateway.deconz_ids + assert "sensor.thermostat_battery_level" in gateway.deconz_ids + assert "climate.presence_sensor" not in gateway.deconz_ids + assert "climate.clip_thermostat" not in gateway.deconz_ids + assert len(hass.states.async_all()) == 3 + + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True} + ) + await hass.async_block_till_done() + + assert "climate.thermostat" in gateway.deconz_ids + assert "sensor.thermostat" not in gateway.deconz_ids + assert "sensor.thermostat_battery_level" in gateway.deconz_ids + assert "climate.presence_sensor" not in gateway.deconz_ids + assert "climate.clip_thermostat" in gateway.deconz_ids + assert len(hass.states.async_all()) == 4 + async def test_verify_state_update(hass): """Test that state update properly.""" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index d79f80b96b0..4873528d982 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -3,13 +3,23 @@ import asyncio import pydeconz +from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.components.deconz import config_flow +from homeassistant.components.deconz.config_flow import ( + CONF_SERIAL, + DECONZ_MANUFACTURERURL, +) +from homeassistant.components.deconz.const import ( + CONF_ALLOW_CLIP_SENSOR, + CONF_ALLOW_DECONZ_GROUPS, + CONF_MASTER_GATEWAY, + DOMAIN, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from .test_gateway import API_KEY, BRIDGEID, setup_deconz_integration -from tests.common import MockConfigEntry - async def test_flow_1_discovered_bridge(hass, aioclient_mock): """Test that config flow for one discovered bridge works.""" @@ -20,10 +30,10 @@ async def test_flow_1_discovered_bridge(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -36,12 +46,12 @@ async def test_flow_1_discovered_bridge(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == BRIDGEID assert result["data"] == { - config_flow.CONF_HOST: "1.2.3.4", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, } @@ -57,17 +67,17 @@ async def test_flow_2_discovered_bridges(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={config_flow.CONF_HOST: "1.2.3.4"} + result["flow_id"], user_input={CONF_HOST: "1.2.3.4"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -80,12 +90,12 @@ async def test_flow_2_discovered_bridges(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == BRIDGEID assert result["data"] == { - config_flow.CONF_HOST: "1.2.3.4", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, } @@ -98,18 +108,17 @@ async def test_flow_manual_configuration(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80}, + result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -128,12 +137,12 @@ async def test_flow_manual_configuration(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == BRIDGEID assert result["data"] == { - config_flow.CONF_HOST: "1.2.3.4", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, } @@ -142,10 +151,10 @@ async def test_manual_configuration_after_discovery_timeout(hass, aioclient_mock aioclient_mock.get(pydeconz.utils.URL_DISCOVER, exc=asyncio.TimeoutError) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" assert not hass.config_entries.flow._progress[result["flow_id"]].bridges @@ -155,10 +164,10 @@ async def test_manual_configuration_after_discovery_ResponseError(hass, aioclien aioclient_mock.get(pydeconz.utils.URL_DISCOVER, exc=config_flow.ResponseError) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" assert not hass.config_entries.flow._progress[result["flow_id"]].bridges @@ -174,18 +183,17 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={config_flow.CONF_HOST: "2.3.4.5", config_flow.CONF_PORT: 80}, + result["flow_id"], user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 80}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -204,9 +212,9 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5" + assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5" async def test_manual_configuration_dont_update_configuration(hass, aioclient_mock): @@ -220,18 +228,17 @@ async def test_manual_configuration_dont_update_configuration(hass, aioclient_mo ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80}, + result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -250,7 +257,7 @@ async def test_manual_configuration_dont_update_configuration(hass, aioclient_mo result["flow_id"], user_input={} ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" @@ -263,18 +270,17 @@ async def test_manual_configuration_timeout_get_bridge(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_PORT: 80}, + result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -291,7 +297,7 @@ async def test_manual_configuration_timeout_get_bridge(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "no_bridges" @@ -304,10 +310,10 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock): ) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post("http://1.2.3.4:80/api", exc=pydeconz.errors.ResponseError) @@ -316,7 +322,7 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" assert result["errors"] == {"base": "no_key"} @@ -324,16 +330,16 @@ async def test_link_get_api_key_ResponseError(hass, aioclient_mock): async def test_flow_ssdp_discovery(hass, aioclient_mock): """Test that config flow for one discovered bridge works.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL, + ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, ssdp.ATTR_UPNP_SERIAL: BRIDGEID, }, context={"source": "ssdp"}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "link" aioclient_mock.post( @@ -346,24 +352,24 @@ async def test_flow_ssdp_discovery(hass, aioclient_mock): result["flow_id"], user_input={} ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == BRIDGEID assert result["data"] == { - config_flow.CONF_HOST: "1.2.3.4", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, } async def test_ssdp_discovery_not_deconz_bridge(hass): """Test a non deconz bridge being discovered over ssdp.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ssdp.ATTR_UPNP_MANUFACTURER_URL: "not deconz bridge"}, context={"source": "ssdp"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_deconz_bridge" @@ -372,18 +378,18 @@ async def test_ssdp_discovery_update_configuration(hass): gateway = await setup_deconz_integration(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL, + ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, ssdp.ATTR_UPNP_SERIAL: BRIDGEID, }, context={"source": "ssdp"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5" + assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5" async def test_ssdp_discovery_dont_update_configuration(hass): @@ -391,18 +397,18 @@ async def test_ssdp_discovery_dont_update_configuration(hass): gateway = await setup_deconz_integration(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL, + ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, ssdp.ATTR_UPNP_SERIAL: BRIDGEID, }, context={"source": "ssdp"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert gateway.config_entry.data[config_flow.CONF_HOST] == "1.2.3.4" + assert gateway.config_entry.data[CONF_HOST] == "1.2.3.4" async def test_ssdp_discovery_dont_update_existing_hassio_configuration(hass): @@ -410,34 +416,34 @@ async def test_ssdp_discovery_dont_update_existing_hassio_configuration(hass): gateway = await setup_deconz_integration(hass, source="hassio") result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ ssdp.ATTR_SSDP_LOCATION: "http://1.2.3.4:80/", - ssdp.ATTR_UPNP_MANUFACTURER_URL: config_flow.DECONZ_MANUFACTURERURL, + ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL, ssdp.ATTR_UPNP_SERIAL: BRIDGEID, }, context={"source": "ssdp"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert gateway.config_entry.data[config_flow.CONF_HOST] == "1.2.3.4" + assert gateway.config_entry.data[CONF_HOST] == "1.2.3.4" async def test_flow_hassio_discovery(hass): """Test hassio discovery flow works.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ "addon": "Mock Addon", - config_flow.CONF_HOST: "mock-deconz", - config_flow.CONF_PORT: 80, - config_flow.CONF_SERIAL: BRIDGEID, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "mock-deconz", + CONF_PORT: 80, + CONF_SERIAL: BRIDGEID, + CONF_API_KEY: API_KEY, }, context={"source": "hassio"}, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "hassio_confirm" assert result["description_placeholders"] == {"addon": "Mock Addon"} @@ -445,11 +451,11 @@ async def test_flow_hassio_discovery(hass): result["flow_id"], user_input={} ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["result"].data == { - config_flow.CONF_HOST: "mock-deconz", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, + CONF_HOST: "mock-deconz", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, } @@ -458,21 +464,21 @@ async def test_hassio_discovery_update_configuration(hass): gateway = await setup_deconz_integration(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ - config_flow.CONF_HOST: "2.3.4.5", - config_flow.CONF_PORT: 8080, - config_flow.CONF_API_KEY: "updated", - config_flow.CONF_SERIAL: BRIDGEID, + CONF_HOST: "2.3.4.5", + CONF_PORT: 8080, + CONF_API_KEY: "updated", + CONF_SERIAL: BRIDGEID, }, context={"source": "hassio"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" - assert gateway.config_entry.data[config_flow.CONF_HOST] == "2.3.4.5" - assert gateway.config_entry.data[config_flow.CONF_PORT] == 8080 - assert gateway.config_entry.data[config_flow.CONF_API_KEY] == "updated" + assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5" + assert gateway.config_entry.data[CONF_PORT] == 8080 + assert gateway.config_entry.data[CONF_API_KEY] == "updated" async def test_hassio_discovery_dont_update_configuration(hass): @@ -480,41 +486,37 @@ async def test_hassio_discovery_dont_update_configuration(hass): await setup_deconz_integration(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, data={ - config_flow.CONF_HOST: "1.2.3.4", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: API_KEY, - config_flow.CONF_SERIAL: BRIDGEID, + CONF_HOST: "1.2.3.4", + CONF_PORT: 80, + CONF_API_KEY: API_KEY, + CONF_SERIAL: BRIDGEID, }, context={"source": "hassio"}, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" async def test_option_flow(hass): """Test config flow options.""" - entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=None) - hass.config_entries._entries.append(entry) + gateway = await setup_deconz_integration(hass) - flow = await hass.config_entries.options.async_create_flow( - entry.entry_id, context={"source": "test"}, data=None - ) + result = await hass.config_entries.options.async_init(gateway.config_entry.entry_id) - result = await flow.async_step_init() - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "deconz_devices" - result = await flow.async_step_deconz_devices( - user_input={ - config_flow.CONF_ALLOW_CLIP_SENSOR: False, - config_flow.CONF_ALLOW_DECONZ_GROUPS: False, - } + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_ALLOW_CLIP_SENSOR: False, CONF_ALLOW_DECONZ_GROUPS: False}, ) - assert result["type"] == "create_entry" + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { - config_flow.CONF_ALLOW_CLIP_SENSOR: False, - config_flow.CONF_ALLOW_DECONZ_GROUPS: False, + CONF_ALLOW_CLIP_SENSOR: False, + CONF_ALLOW_DECONZ_GROUPS: False, + CONF_MASTER_GATEWAY: True, } diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 8990e1ff236..e39722fdacb 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -245,3 +245,29 @@ async def test_disable_light_groups(hass): empty_group = hass.states.get("light.empty_group") assert empty_group is None + + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: True} + ) + await hass.async_block_till_done() + + assert "light.rgb_light" in gateway.deconz_ids + assert "light.tunable_white_light" in gateway.deconz_ids + assert "light.light_group" in gateway.deconz_ids + assert "light.empty_group" not in gateway.deconz_ids + assert "light.on_off_switch" not in gateway.deconz_ids + # 3 entities + assert len(hass.states.async_all()) == 5 + + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: False} + ) + await hass.async_block_till_done() + + assert "light.rgb_light" in gateway.deconz_ids + assert "light.tunable_white_light" in gateway.deconz_ids + assert "light.light_group" not in gateway.deconz_ids + assert "light.empty_group" not in gateway.deconz_ids + assert "light.on_off_switch" not in gateway.deconz_ids + # 3 entities + assert len(hass.states.async_all()) == 4 diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index 3e151b55890..cda3138557d 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -218,6 +218,40 @@ async def test_allow_clip_sensors(hass): clip_light_level_sensor = hass.states.get("sensor.clip_light_level_sensor") assert clip_light_level_sensor.state == "999.8" + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: False} + ) + await hass.async_block_till_done() + + assert "sensor.light_level_sensor" in gateway.deconz_ids + assert "sensor.presence_sensor" not in gateway.deconz_ids + assert "sensor.switch_1" not in gateway.deconz_ids + assert "sensor.switch_1_battery_level" not in gateway.deconz_ids + assert "sensor.switch_2" not in gateway.deconz_ids + assert "sensor.switch_2_battery_level" in gateway.deconz_ids + assert "sensor.daylight_sensor" not in gateway.deconz_ids + assert "sensor.power_sensor" in gateway.deconz_ids + assert "sensor.consumption_sensor" in gateway.deconz_ids + assert "sensor.clip_light_level_sensor" not in gateway.deconz_ids + assert len(hass.states.async_all()) == 5 + + hass.config_entries.async_update_entry( + gateway.config_entry, options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True} + ) + await hass.async_block_till_done() + + assert "sensor.light_level_sensor" in gateway.deconz_ids + assert "sensor.presence_sensor" not in gateway.deconz_ids + assert "sensor.switch_1" not in gateway.deconz_ids + assert "sensor.switch_1_battery_level" not in gateway.deconz_ids + assert "sensor.switch_2" not in gateway.deconz_ids + assert "sensor.switch_2_battery_level" in gateway.deconz_ids + assert "sensor.daylight_sensor" not in gateway.deconz_ids + assert "sensor.power_sensor" in gateway.deconz_ids + assert "sensor.consumption_sensor" in gateway.deconz_ids + assert "sensor.clip_light_level_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 6 + async def test_add_new_sensor(hass): """Test that adding a new sensor works.""" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f9f25192211..627bf23341d 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -126,6 +126,16 @@ async def test_themes_api(hass, hass_ws_client): assert msg["result"]["default_theme"] == "default" assert msg["result"]["themes"] == {"happy": {"primary-color": "red"}} + # safe mode + hass.config.safe_mode = True + await client.send_json({"id": 6, "type": "frontend/get_themes"}) + msg = await client.receive_json() + + assert msg["result"]["default_theme"] == "safe_mode" + assert msg["result"]["themes"] == { + "safe_mode": {"primary-color": "#db4437", "accent-color": "#eeee02"} + } + async def test_themes_set_theme(hass, hass_ws_client): """Test frontend.set_theme service.""" diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index 6980f6fd5d7..7da1a94bdd7 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -205,3 +205,139 @@ async def test_hmip_garage_door_tormatic(hass, default_mock_hap_factory): assert len(hmip_device.mock_calls) == service_call_counter + 5 assert hmip_device.mock_calls[-1][0] == "send_door_command" assert hmip_device.mock_calls[-1][1] == (DoorCommand.STOP,) + + +async def test_hmip_cover_shutter_group(hass, default_mock_hap_factory): + """Test HomematicipCoverShutteGroup.""" + entity_id = "cover.rollos_shuttergroup" + entity_name = "Rollos ShutterGroup" + device_model = None + mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Rollos"]) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "closed" + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0 + service_call_counter = len(hmip_device.mock_calls) + + await hass.services.async_call( + "cover", "open_cover", {"entity_id": entity_id}, blocking=True + ) + assert len(hmip_device.mock_calls) == service_call_counter + 1 + assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][1] == (0,) + await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_OPEN + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 100 + + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": entity_id, "position": "50"}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 3 + assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][1] == (0.5,) + await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_OPEN + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50 + + await hass.services.async_call( + "cover", "close_cover", {"entity_id": entity_id}, blocking=True + ) + assert len(hmip_device.mock_calls) == service_call_counter + 5 + assert hmip_device.mock_calls[-1][0] == "set_shutter_level" + assert hmip_device.mock_calls[-1][1] == (1,) + await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_CLOSED + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0 + + await hass.services.async_call( + "cover", "stop_cover", {"entity_id": entity_id}, blocking=True + ) + assert len(hmip_device.mock_calls) == service_call_counter + 7 + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][1] == () + + await async_manipulate_test_data(hass, hmip_device, "shutterLevel", None) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_UNKNOWN + + +async def test_hmip_cover_slats_group(hass, default_mock_hap_factory): + """Test slats with HomematicipCoverShutteGroup.""" + entity_id = "cover.rollos_shuttergroup" + entity_name = "Rollos ShutterGroup" + device_model = None + mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_groups=["Rollos"]) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) + ha_state = hass.states.get(entity_id) + + assert ha_state.state == STATE_CLOSED + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 0 + assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + service_call_counter = len(hmip_device.mock_calls) + + await hass.services.async_call( + "cover", + "set_cover_position", + {"entity_id": entity_id, "position": "50"}, + blocking=True, + ) + await hass.services.async_call( + "cover", "open_cover_tilt", {"entity_id": entity_id}, blocking=True + ) + + assert len(hmip_device.mock_calls) == service_call_counter + 2 + assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][1] == (0,) + await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0.5) + await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_OPEN + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50 + assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": entity_id, "tilt_position": "50"}, + blocking=True, + ) + assert len(hmip_device.mock_calls) == service_call_counter + 5 + assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][1] == (0.5,) + await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_OPEN + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50 + assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + await hass.services.async_call( + "cover", "close_cover_tilt", {"entity_id": entity_id}, blocking=True + ) + assert len(hmip_device.mock_calls) == service_call_counter + 7 + assert hmip_device.mock_calls[-1][0] == "set_slats_level" + assert hmip_device.mock_calls[-1][1] == (1,) + await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == STATE_OPEN + assert ha_state.attributes[ATTR_CURRENT_POSITION] == 50 + assert ha_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + await hass.services.async_call( + "cover", "stop_cover_tilt", {"entity_id": entity_id}, blocking=True + ) + assert len(hmip_device.mock_calls) == service_call_counter + 9 + assert hmip_device.mock_calls[-1][0] == "set_shutter_stop" + assert hmip_device.mock_calls[-1][1] == () diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 4334a049564..c678bee5e32 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -15,6 +15,15 @@ from .helper import ( ) +async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory): + """Ensure that all supported devices could be loaded.""" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=None, test_groups=None + ) + + assert len(mock_hap.hmip_device_by_entity_id) == 183 + + async def test_hmip_remove_device(hass, default_mock_hap_factory): """Test Remove of hmip device.""" entity_id = "light.treppe" diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index be5a7423463..2ca36e228cc 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -165,6 +165,31 @@ async def test_hmip_temperature_sensor2(hass, default_mock_hap_factory): assert ha_state.attributes[ATTR_TEMPERATURE_OFFSET] == 10 +async def test_hmip_temperature_sensor3(hass, default_mock_hap_factory): + """Test HomematicipTemperatureSensor.""" + entity_id = "sensor.raumbediengerat_analog_temperature" + entity_name = "Raumbediengerät Analog Temperature" + device_model = "ALPHA-IP-RBGa" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Raumbediengerät Analog"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "23.3" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + await async_manipulate_test_data(hass, hmip_device, "actualTemperature", 23.5) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "23.5" + + assert not ha_state.attributes.get(ATTR_TEMPERATURE_OFFSET) + await async_manipulate_test_data(hass, hmip_device, "temperatureOffset", 10) + ha_state = hass.states.get(entity_id) + assert ha_state.attributes[ATTR_TEMPERATURE_OFFSET] == 10 + + async def test_hmip_power_sensor(hass, default_mock_hap_factory): """Test HomematicipPowerSensor.""" entity_id = "sensor.flur_oben_power" diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py index 9f1c62a8b13..82e7b3bc2ac 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_init.py @@ -38,6 +38,13 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): assert response["result"] == {"yo": "hello"} + # Test with safe mode + hass.config.safe_mode = True + await client.send_json({"id": 8, "type": "lovelace/config"}) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "config_not_found" + async def test_lovelace_from_storage_save_before_load( hass, hass_ws_client, hass_storage diff --git a/tests/components/modbus/test_modbus_sensor.py b/tests/components/modbus/test_modbus_sensor.py index 9f13cba8907..16d8f9a1936 100644 --- a/tests/components/modbus/test_modbus_sensor.py +++ b/tests/components/modbus/test_modbus_sensor.py @@ -17,8 +17,8 @@ from homeassistant.components.modbus.sensor import ( DATA_TYPE_FLOAT, DATA_TYPE_INT, DATA_TYPE_UINT, - REGISTER_TYPE_HOLDING, - REGISTER_TYPE_INPUT, + DEFAULT_REGISTER_TYPE_HOLDING, + DEFAULT_REGISTER_TYPE_INPUT, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( @@ -72,7 +72,7 @@ async def run_test(hass, mock_hub, register_config, register_words, expected): # Setup inputs for the sensor read_result = ReadResult(register_words) - if register_config.get(CONF_REGISTER_TYPE) == REGISTER_TYPE_INPUT: + if register_config.get(CONF_REGISTER_TYPE) == DEFAULT_REGISTER_TYPE_INPUT: mock_hub.read_input_registers.return_value = read_result else: mock_hub.read_holding_registers.return_value = read_result @@ -310,7 +310,7 @@ async def test_two_word_input_register(hass, mock_hub): """Test reaging of input register.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: REGISTER_TYPE_INPUT, + CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_INPUT, CONF_DATA_TYPE: DATA_TYPE_UINT, CONF_SCALE: 1, CONF_OFFSET: 0, @@ -329,7 +329,7 @@ async def test_two_word_holding_register(hass, mock_hub): """Test reaging of holding register.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: REGISTER_TYPE_HOLDING, + CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING, CONF_DATA_TYPE: DATA_TYPE_UINT, CONF_SCALE: 1, CONF_OFFSET: 0, @@ -348,7 +348,7 @@ async def test_float_data_type(hass, mock_hub): """Test floating point register data type.""" register_config = { CONF_COUNT: 2, - CONF_REGISTER_TYPE: REGISTER_TYPE_HOLDING, + CONF_REGISTER_TYPE: DEFAULT_REGISTER_TYPE_HOLDING, CONF_DATA_TYPE: DATA_TYPE_FLOAT, CONF_SCALE: 1, CONF_OFFSET: 0, diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py new file mode 100644 index 00000000000..c3ba6eebadd --- /dev/null +++ b/tests/components/mqtt/test_device_trigger.py @@ -0,0 +1,777 @@ +"""The tests for MQTT device triggers.""" +import json + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.mqtt import DOMAIN +from homeassistant.components.mqtt.device_trigger import async_attach_trigger +from homeassistant.components.mqtt.discovery import async_start +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + async_fire_mqtt_message, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers(hass, device_reg, entity_reg, mqtt_mock): + """Test we get the expected triggers from a discovered mqtt device.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla", + "type": "button_short_press", + "subtype": "button_1", + }, + ] + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_get_unknown_triggers(hass, device_reg, entity_reg, mqtt_mock): + """Test we don't get unknown triggers.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + # Discover a sensor (without device triggers) + data1 = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + ] + }, + ) + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, []) + + +async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock): + """Test getting non existing triggers.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + # Discover a sensor (without device triggers) + data1 = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, []) + + +async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock): + """Test bad discovery message.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + # Test sending bad data + data0 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payloads": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data0) + await hass.async_block_till_done() + assert device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) is None + + # Test sending correct data + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla", + "type": "button_short_press", + "subtype": "button_1", + }, + ] + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers) + + +async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock): + """Test triggers can be updated and removed.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data1) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + expected_triggers1 = [ + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla", + "type": "button_short_press", + "subtype": "button_1", + }, + ] + expected_triggers2 = [dict(expected_triggers1[0])] + expected_triggers2[0]["subtype"] = "button_2" + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers1) + + # Update trigger + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data2) + await hass.async_block_till_done() + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, expected_triggers2) + + # Remove trigger + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", "") + await hass.async_block_till_done() + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert_lists_same(triggers, []) + + +async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock): + """Test triggers firing.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_long_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla2", + "type": "button_1", + "subtype": "button_long_press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("long_press")}, + }, + }, + ] + }, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "short_press" + + # Fake long press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "long_press" + + +async def test_if_fires_on_mqtt_message_late_discover( + hass, device_reg, calls, mqtt_mock +): + """Test triggers firing of MQTT device triggers discovered after setup.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data0 = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "long_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_long_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla2", + "type": "button_1", + "subtype": "button_long_press", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("long_press")}, + }, + }, + ] + }, + ) + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "short_press" + + # Fake long press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "long_press") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "long_press" + + +async def test_if_fires_on_mqtt_message_after_update( + hass, device_reg, calls, mqtt_mock +): + """Test triggers firing after update.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + data2 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/buttonOne",' + ' "type": "button_long_press",' + ' "subtype": "button_2" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + ] + }, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Update the trigger + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data2) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/triggers/button1", "") + await hass.async_block_till_done() + assert len(calls) == 1 + + async_fire_mqtt_message(hass, "foobar/triggers/buttonOne", "") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_not_fires_on_mqtt_message_after_remove( + hass, device_reg, calls, mqtt_mock +): + """Test triggers not firing after removal.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + ] + }, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Remove the trigger + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", "") + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Rediscover the trigger + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 2 + + +async def test_attach_remove(hass, device_reg, mqtt_mock): + """Test attach and removal of trigger.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + calls = [] + + def callback(trigger): + calls.append(trigger["trigger"]["payload"]) + + remove = await async_attach_trigger( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + callback, + None, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0] == "short_press" + + # Remove the trigger + remove() + await hass.async_block_till_done() + + # Verify the triggers are no longer active + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_attach_remove_late(hass, device_reg, mqtt_mock): + """Test attach and removal of trigger .""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data0 = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + calls = [] + + def callback(trigger): + calls.append(trigger["trigger"]["payload"]) + + remove = await async_attach_trigger( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + callback, + None, + ) + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0] == "short_press" + + # Remove the trigger + remove() + await hass.async_block_till_done() + + # Verify the triggers are no longer active + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + +async def test_attach_remove_late2(hass, device_reg, mqtt_mock): + """Test attach and removal of trigger .""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data0 = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "payload": "short_press",' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/sensor/bla0/config", data0) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + calls = [] + + def callback(trigger): + calls.append(trigger["trigger"]["payload"]) + + remove = await async_attach_trigger( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + callback, + None, + ) + + # Remove the trigger + remove() + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + + # Verify the triggers are no longer active + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 0 + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT device registry integration.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps( + { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + } + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.identifiers == {("mqtt", "helloworld")} + assert device.connections == {("mac", "02:5b:26:a8:dc:12")} + assert device.manufacturer == "Whatever" + assert device.name == "Beer" + assert device.model == "Glass" + assert device.sw_version == "0.1-beta" + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Beer" + + config["device"]["name"] = "Milk" + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Milk" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e8da9b53a5e..e09b4d786a6 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -250,7 +250,7 @@ async def test_discovery_expansion(hass, mqtt_mock, caplog): ABBREVIATIONS_WHITE_LIST = [ - # MQTT client/server settings + # MQTT client/server/trigger settings "CONF_BIRTH_MESSAGE", "CONF_BROKER", "CONF_CERTIFICATE", @@ -258,6 +258,7 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_CLIENT_ID", "CONF_CLIENT_KEY", "CONF_DISCOVERY", + "CONF_DISCOVERY_ID", "CONF_DISCOVERY_PREFIX", "CONF_EMBEDDED", "CONF_KEEPALIVE", diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index c4fccb35bb0..6e61dfac3ab 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -1,4 +1,6 @@ """Mock classes used in tests.""" +import itertools + from homeassistant.components.plex.const import CONF_SERVER, CONF_SERVER_IDENTIFIER from homeassistant.const import CONF_HOST, CONF_PORT @@ -17,6 +19,12 @@ MOCK_SERVERS = [ }, ] +MOCK_MONITORED_USERS = { + "a": {"enabled": True}, + "b": {"enabled": False}, + "c": {"enabled": True}, +} + class MockResource: """Mock a PlexAccount resource.""" @@ -65,7 +73,14 @@ class MockPlexSystemAccount: class MockPlexServer: """Mock a PlexServer instance.""" - def __init__(self, index=0, ssl=True): + def __init__( + self, + index=0, + ssl=True, + load_users=True, + num_users=len(MOCK_MONITORED_USERS), + ignore_new_users=False, + ): """Initialize the object.""" host = MOCK_SERVERS[index][CONF_HOST] port = MOCK_SERVERS[index][CONF_PORT] @@ -78,11 +93,24 @@ class MockPlexServer: prefix = "https" if ssl else "http" self._baseurl = f"{prefix}://{host}:{port}" self._systemAccount = MockPlexSystemAccount() + self._ignore_new_users = ignore_new_users + self._load_users = load_users + self._num_users = num_users def systemAccounts(self): """Mock the systemAccounts lookup method.""" return [self._systemAccount] + @property + def accounts(self): + """Mock the accounts property.""" + return set(["a", "b", "c"]) + + @property + def owner(self): + """Mock the owner property.""" + return "a" + @property def url_in_use(self): """Return URL used by PlexServer.""" @@ -92,3 +120,24 @@ class MockPlexServer: def version(self): """Mock version of PlexServer.""" return "1.0" + + @property + def option_monitored_users(self): + """Mock loaded config option for monitored users.""" + userdict = dict(itertools.islice(MOCK_MONITORED_USERS.items(), self._num_users)) + return userdict if self._load_users else {} + + @property + def option_ignore_new_shared_users(self): + """Mock loaded config option for ignoring new users.""" + return self._ignore_new_users + + @property + def option_show_all_controls(self): + """Mock loaded config option for showing all controls.""" + return False + + @property + def option_use_episode_art(self): + """Mock loaded config option for using episode art.""" + return False diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 8f9342c4f72..b331444123a 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Plex config flow.""" +import copy from unittest.mock import patch import asynctest @@ -26,6 +27,7 @@ DEFAULT_OPTIONS = { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: False, config_flow.CONF_SHOW_ALL_CONTROLS: False, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: False, } } @@ -457,9 +459,20 @@ async def test_all_available_servers_configured(hass): async def test_option_flow(hass): - """Test config flow selection of one of two bridges.""" + """Test config options flow selection.""" - entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=DEFAULT_OPTIONS) + mock_plex_server = MockPlexServer(load_users=False) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=DEFAULT_OPTIONS, + ) entry.add_to_hass(hass) result = await hass.config_entries.options.async_init( @@ -473,6 +486,8 @@ async def test_option_flow(hass): user_input={ config_flow.CONF_USE_EPISODE_ART: True, config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) assert result["type"] == "create_entry" @@ -480,6 +495,105 @@ async def test_option_flow(hass): config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: True, config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, + } + } + + +async def test_option_flow_loading_saved_users(hass): + """Test config options flow selection when loading existing user config.""" + + mock_plex_server = MockPlexServer(load_users=True) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=DEFAULT_OPTIONS, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"}, data=None + ) + assert result["type"] == "form" + assert result["step_id"] == "plex_mp_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + config_flow.MP_DOMAIN: { + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, + } + } + + +async def test_option_flow_new_users_available(hass): + """Test config options flow selection when new Plex accounts available.""" + + mock_plex_server = MockPlexServer(load_users=True, num_users=2) + + MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] + hass.data[config_flow.DOMAIN] = { + config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} + } + + OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) + OPTIONS_WITH_USERS[config_flow.MP_DOMAIN][config_flow.CONF_MONITORED_USERS] = { + "a": {"enabled": True} + } + + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + options=OPTIONS_WITH_USERS, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init( + entry.entry_id, context={"source": "test"}, data=None + ) + assert result["type"] == "form" + assert result["step_id"] == "plex_mp_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), + }, + ) + assert result["type"] == "create_entry" + assert result["data"] == { + config_flow.MP_DOMAIN: { + config_flow.CONF_USE_EPISODE_ART: True, + config_flow.CONF_SHOW_ALL_CONTROLS: True, + config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, + config_flow.CONF_MONITORED_USERS: { + user: {"enabled": True} for user in mock_plex_server.accounts + }, } } diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py new file mode 100644 index 00000000000..ec26cf264ef --- /dev/null +++ b/tests/components/twitch/__init__.py @@ -0,0 +1 @@ +"""Tests for the Twitch component.""" diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py new file mode 100644 index 00000000000..6c656f874d0 --- /dev/null +++ b/tests/components/twitch/test_twitch.py @@ -0,0 +1,174 @@ +"""The tests for an update of the Twitch component.""" +from unittest.mock import MagicMock, patch + +from requests import HTTPError +from twitch.resources import Channel, Follow, Stream, Subscription, User + +from homeassistant.components import sensor +from homeassistant.setup import async_setup_component + +ENTITY_ID = "sensor.channel123" +CONFIG = { + sensor.DOMAIN: { + "platform": "twitch", + "client_id": "1234", + "channels": ["channel123"], + } +} +CONFIG_WITH_OAUTH = { + sensor.DOMAIN: { + "platform": "twitch", + "client_id": "1234", + "channels": ["channel123"], + "token": "9876", + } +} + +USER_ID = User({"id": 123, "display_name": "channel123", "logo": "logo.png"}) +STREAM_OBJECT_ONLINE = Stream( + { + "channel": {"game": "Good Game", "status": "Title"}, + "preview": {"medium": "stream-medium.png"}, + } +) +CHANNEL_OBJECT = Channel({"followers": 42, "views": 24}) +OAUTH_USER_ID = User({"id": 987}) +SUB_ACTIVE = Subscription({"created_at": "2020-01-20T21:22:42", "is_gift": False}) +FOLLOW_ACTIVE = Follow({"created_at": "2020-01-20T21:22:42"}) + + +async def test_init(hass): + """Test initial config.""" + + channels = MagicMock() + channels.get_by_id.return_value = CHANNEL_OBJECT + streams = MagicMock() + streams.get_stream_by_user.return_value = None + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels = channels + twitch_mock.streams = streams + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.name == "channel123" + assert sensor_state.attributes["icon"] == "mdi:twitch" + assert sensor_state.attributes["friendly_name"] == "channel123" + assert sensor_state.attributes["views"] == 24 + assert sensor_state.attributes["followers"] == 42 + + +async def test_offline(hass): + """Test offline state.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock.streams.get_stream_by_user.return_value = None + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "offline" + assert sensor_state.attributes["entity_picture"] == "logo.png" + + +async def test_streaming(hass): + """Test streaming state.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.state == "streaming" + assert sensor_state.attributes["entity_picture"] == "stream-medium.png" + assert sensor_state.attributes["game"] == "Good Game" + assert sensor_state.attributes["title"] == "Title" + + +async def test_oauth_without_sub_and_follow(hass): + """Test state with oauth.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() + twitch_mock.users.check_follows_channel.side_effect = HTTPError() + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_sub(hass): + """Test state with oauth and sub.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.return_value = SUB_ACTIVE + twitch_mock.users.check_follows_channel.side_effect = HTTPError() + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is True + assert sensor_state.attributes["subscribed_since"] == "2020-01-20T21:22:42" + assert sensor_state.attributes["subscription_is_gifted"] is False + assert sensor_state.attributes["following"] is False + + +async def test_oauth_with_follow(hass): + """Test state with oauth and follow.""" + + twitch_mock = MagicMock() + twitch_mock.users.translate_usernames_to_ids.return_value = [USER_ID] + twitch_mock.channels.get_by_id.return_value = CHANNEL_OBJECT + twitch_mock._oauth_token = True # A replacement for the token + twitch_mock.users.get.return_value = OAUTH_USER_ID + twitch_mock.users.check_subscribed_to_channel.side_effect = HTTPError() + twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE + + with patch( + "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock, + ): + assert ( + await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) is True + ) + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["subscribed"] is False + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index c2c89d2b9c0..64d1ab9775e 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -2,6 +2,7 @@ import aiounifi from asynctest import patch +from homeassistant import data_entry_flow from homeassistant.components import unifi from homeassistant.components.unifi import config_flow from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID @@ -13,8 +14,12 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) +from .test_controller import setup_unifi_integration + from tests.common import MockConfigEntry +WLANS = [{"name": "SSID 1"}, {"name": "SSID 2"}] + async def test_flow_works(hass, aioclient_mock, mock_discovery): """Test config flow.""" @@ -236,36 +241,39 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock): async def test_option_flow(hass): """Test config flow options.""" - entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=None) - hass.config_entries._entries.append(entry) + controller = await setup_unifi_integration(hass, wlans_response=WLANS) - flow = await hass.config_entries.options.async_create_flow( - entry.entry_id, context={"source": "test"}, data=None + result = await hass.config_entries.options.async_init( + controller.config_entry.entry_id ) - result = await flow.async_step_init() - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "device_tracker" - result = await flow.async_step_device_tracker( + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={ config_flow.CONF_TRACK_CLIENTS: False, config_flow.CONF_TRACK_WIRED_CLIENTS: False, config_flow.CONF_TRACK_DEVICES: False, + config_flow.CONF_SSID_FILTER: ["SSID 1"], config_flow.CONF_DETECTION_TIME: 100, - } + }, ) - assert result["type"] == "form" + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "statistics_sensors" - result = await flow.async_step_statistics_sensors( - user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True} ) - assert result["type"] == "create_entry" + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { config_flow.CONF_TRACK_CLIENTS: False, config_flow.CONF_TRACK_WIRED_CLIENTS: False, config_flow.CONF_TRACK_DEVICES: False, config_flow.CONF_DETECTION_TIME: 100, + config_flow.CONF_SSID_FILTER: ["SSID 1"], config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True, } diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index e1b2b2355c4..daec8cddf5d 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -63,6 +63,7 @@ async def setup_unifi_integration( clients_response=None, devices_response=None, clients_all_response=None, + wlans_response=None, known_wireless_clients=None, controllers=None, ): @@ -98,6 +99,10 @@ async def setup_unifi_integration( if clients_all_response: mock_client_all_responses.append(clients_all_response) + mock_wlans_responses = deque() + if wlans_response: + mock_wlans_responses.append(wlans_response) + mock_requests = [] async def mock_request(self, method, path, json=None): @@ -109,6 +114,8 @@ async def setup_unifi_integration( return mock_device_responses.popleft() if path == "s/{site}/rest/user" and mock_client_all_responses: return mock_client_all_responses.popleft() + if path == "s/{site}/rest/wlanconf" and mock_wlans_responses: + return mock_wlans_responses.popleft() return {} # "aiounifi.Controller.start_websocket", return_value=True @@ -128,6 +135,7 @@ async def setup_unifi_integration( controller.mock_client_responses = mock_client_responses controller.mock_device_responses = mock_device_responses controller.mock_client_all_responses = mock_client_all_responses + controller.mock_wlans_responses = mock_wlans_responses controller.mock_requests = mock_requests return controller diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index c726d3bb1cb..7d0600f5885 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -54,7 +54,7 @@ async def test_no_clients(hass): hass, options={unifi.const.CONF_ALLOW_BANDWIDTH_SENSORS: True}, ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 1 @@ -70,7 +70,7 @@ async def test_sensors(hass): clients_response=CLIENTS, ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 5 wired_client_rx = hass.states.get("sensor.wired_client_name_rx") diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index cd3d8785399..a2b609078de 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -207,7 +207,7 @@ async def test_no_clients(hass): }, ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 1 @@ -223,7 +223,7 @@ async def test_controller_not_client(hass): devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 1 cloudkey = hass.states.get("switch.cloud_key") assert cloudkey is None @@ -244,7 +244,7 @@ async def test_not_admin(hass): devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 1 @@ -262,7 +262,7 @@ async def test_switches(hass): clients_all_response=[BLOCKED, UNBLOCKED, CLIENT_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 4 switch_1 = hass.states.get("switch.poe_client_1") @@ -297,7 +297,7 @@ async def test_new_client_discovered_on_block_control(hass): clients_all_response=[BLOCKED], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 2 controller.api.websocket._data = { @@ -310,9 +310,9 @@ async def test_new_client_discovered_on_block_control(hass): await hass.services.async_call( "switch", "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 4 + assert len(controller.mock_requests) == 5 assert len(hass.states.async_all()) == 2 - assert controller.mock_requests[3] == { + assert controller.mock_requests[4] == { "json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"}, "method": "post", "path": "s/{site}/cmd/stamgr/", @@ -321,8 +321,8 @@ async def test_new_client_discovered_on_block_control(hass): await hass.services.async_call( "switch", "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 5 - assert controller.mock_requests[4] == { + assert len(controller.mock_requests) == 6 + assert controller.mock_requests[5] == { "json": {"mac": "00:00:00:00:01:01", "cmd": "unblock-sta"}, "method": "post", "path": "s/{site}/cmd/stamgr/", @@ -341,7 +341,7 @@ async def test_new_client_discovered_on_poe_control(hass): devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 2 controller.api.websocket._data = { @@ -354,9 +354,9 @@ async def test_new_client_discovered_on_poe_control(hass): await hass.services.async_call( "switch", "turn_off", {"entity_id": "switch.poe_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 4 + assert len(controller.mock_requests) == 5 assert len(hass.states.async_all()) == 3 - assert controller.mock_requests[3] == { + assert controller.mock_requests[4] == { "json": { "port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}] }, @@ -367,8 +367,8 @@ async def test_new_client_discovered_on_poe_control(hass): await hass.services.async_call( "switch", "turn_on", {"entity_id": "switch.poe_client_1"}, blocking=True ) - assert len(controller.mock_requests) == 5 - assert controller.mock_requests[3] == { + assert len(controller.mock_requests) == 6 + assert controller.mock_requests[4] == { "json": { "port_overrides": [ {"port_idx": 1, "portconf_id": "1a1", "poe_mode": "auto"} @@ -393,7 +393,7 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass): hass, clients_response=POE_SWITCH_CLIENTS, devices_response=[DEVICE_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 4 switch_1 = hass.states.get("switch.poe_client_1") @@ -444,7 +444,7 @@ async def test_restoring_client(hass): clients_all_response=[CLIENT_1], ) - assert len(controller.mock_requests) == 3 + assert len(controller.mock_requests) == 4 assert len(hass.states.async_all()) == 3 device_1 = hass.states.get("switch.client_1") diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index 068470313fc..a97b1247e2c 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -14,6 +14,203 @@ } }, "devices": { + "3014F711000BBBB000000000": { + "availableFirmwareVersion": "2.0.2", + "firmwareVersion": "2.0.2", + "firmwareVersionInteger": 131074, + "functionalChannels": { + "0": { + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F711000BBBB000000000", + "deviceOverheated": false, + "deviceOverloaded": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": false, + "operationLockActive": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -45, + "rssiPeerValue": -54, + "supportedOptionalFeatures": { + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false + }, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 23.0, + "deviceId": "3014F711000BBBB000000000", + "display": "ACTUAL", + "functionalChannelType": "WALL_MOUNTED_THERMOSTAT_PRO_CHANNEL", + "groupIndex": 1, + "groups": [], + "humidity": 52, + "index": 1, + "label": "", + "setPointTemperature": 20.0, + "temperatureOffset": 0.0, + "vaporAmount": 10.662700840292974 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000BBBB000000000", + "label": "Raumbedienger\u00e4t", + "lastStatusUpdate": 1579383507353, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 282, + "modelType": "ALPHA-IP-RBG", + "oem": "M\u00f6hlenhoff", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F711000BBBB000000000", + "type": "ROOM_CONTROL_DEVICE", + "updateState": "UP_TO_DATE" + }, + "3014F711000000BBBB000005": { + "availableFirmwareVersion": "1.0.16", + "firmwareVersion": "1.0.12", + "firmwareVersionInteger": 65548, + "functionalChannels": { + "0": { + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F711000000BBBB000005", + "deviceOverheated": false, + "deviceOverloaded": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -41, + "rssiPeerValue": -29, + "supportedOptionalFeatures": { + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false + }, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 23.3, + "deviceId": "3014F711000000BBBB000005", + "functionalChannelType": "ANALOG_ROOM_CONTROL_CHANNEL", + "groupIndex": 1, + "groups": [], + "index": 1, + "label": "", + "setPointTemperature": 23.0, + "temperatureOffset": 0.0 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000000BBBB000005", + "label": "Raumbedienger\u00e4t Analog", + "lastStatusUpdate": 1579384126279, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manufacturerCode": 1, + "modelId": 281, + "modelType": "ALPHA-IP-RBGa", + "oem": "M\u00f6hlenhoff", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F711000000BBBB000005", + "type": "ROOM_CONTROL_DEVICE_ANALOG", + "updateState": "TRANSFERING_UPDATE" + }, + "3014F711000000000000AAA5": { + "availableFirmwareVersion": "0.0.0", + "firmwareVersion": "1.0.12", + "firmwareVersionInteger": 65548, + "functionalChannels": { + "0": { + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "deviceId": "3014F711000000000000AAA5", + "deviceOverheated": false, + "deviceOverloaded": false, + "deviceUndervoltage": false, + "dutyCycle": false, + "functionalChannelType": "DEVICE_OPERATIONLOCK", + "groupIndex": 0, + "groups": [], + "index": 0, + "label": "", + "lowBat": false, + "operationLockActive": false, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -58, + "rssiPeerValue": -59, + "supportedOptionalFeatures": { + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false + }, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "actualTemperature": 16.0, + "deviceId": "3014F711000000000000AAA5", + "display": "ACTUAL", + "functionalChannelType": "WALL_MOUNTED_THERMOSTAT_PRO_CHANNEL", + "groupIndex": 1, + "groups": [], + "humidity": 42, + "index": 1, + "label": "", + "setPointTemperature": 12.0, + "temperatureOffset": 0.0, + "vaporAmount": 5.710127947243264 + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F711000000000000AAA5", + "label": "Thermostat Schlafen Tal", + "lastStatusUpdate": 1578954498192, + "liveUpdateState": "UP_TO_DATE", + "manufacturerCode": 1, + "modelId": 408, + "modelType": "HmIP-WTH-B", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F711000000000000AAA5", + "type": "WALL_MOUNTED_THERMOSTAT_BASIC_HUMIDITY", + "updateState": "BACKGROUND_UPDATE_NOT_SUPPORTED" + }, "3014F7110000000000ABCD50": { "availableFirmwareVersion": "1.0.12", "firmwareVersion": "1.0.12", diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 6782007ebe7..e7a7b856da2 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -159,6 +159,11 @@ async def test_loading_saving_data(hass, registry): supported_features=5, device_class="mock-device-class", disabled_by=entity_registry.DISABLED_HASS, + original_name="Original Name", + original_icon="hass:original-icon", + ) + orig_entry2 = registry.async_update_entity( + orig_entry2.entity_id, name="User Name", icon="hass:user-icon" ) assert len(registry.entities) == 2 @@ -181,6 +186,10 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.capabilities == {"max": 100} assert new_entry2.supported_features == 5 assert new_entry2.device_class == "mock-device-class" + assert new_entry2.name == "User Name" + assert new_entry2.icon == "hass:user-icon" + assert new_entry2.original_name == "Original Name" + assert new_entry2.original_icon == "hass:original-icon" def test_generate_entity_considers_registered_entities(registry): diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 7f653f18f0e..dac32b4728d 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -250,7 +250,8 @@ async def test_setup_hass( log_no_color = Mock() with patch( - "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}} + "homeassistant.config.async_hass_config_yaml", + return_value={"browser": {}, "frontend": {}}, ): hass = await bootstrap.async_setup_hass( config_dir=get_test_config_dir(), @@ -263,6 +264,7 @@ async def test_setup_hass( ) assert "browser" in hass.config.components + assert "safe_mode" not in hass.config.components assert len(mock_enable_logging.mock_calls) == 1 assert mock_enable_logging.mock_calls[0][1] == ( @@ -382,3 +384,32 @@ async def test_setup_hass_invalid_core_config( ) assert "safe_mode" in hass.config.components + + +async def test_setup_safe_mode_if_no_frontend( + mock_enable_logging, + mock_is_virtual_env, + mock_mount_local_lib_path, + mock_ensure_config_exists, + mock_process_ha_config_upgrade, +): + """Test we setup safe mode if frontend didn't load.""" + verbose = Mock() + log_rotate_days = Mock() + log_file = Mock() + log_no_color = Mock() + + with patch( + "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}} + ): + hass = await bootstrap.async_setup_hass( + config_dir=get_test_config_dir(), + verbose=verbose, + log_rotate_days=log_rotate_days, + log_file=log_file, + log_no_color=log_no_color, + skip_pip=True, + safe_mode=False, + ) + + assert "safe_mode" in hass.config.components diff --git a/tests/test_core.py b/tests/test_core.py index 657bbeda7c6..0c7acfbba0e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -904,6 +904,7 @@ class TestConfig(unittest.TestCase): "whitelist_external_dirs": set(), "version": __version__, "config_source": "default", + "safe_mode": False, } assert expected == self.config.as_dict() diff --git a/tests/test_loader.py b/tests/test_loader.py index 47d9e4e23fa..745bb9c8c2c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -236,3 +236,9 @@ async def test_get_config_flows(hass): flows = await loader.async_get_config_flows(hass) assert "test_2" in flows assert "test_1" not in flows + + +async def test_get_custom_components_safe_mode(hass): + """Test that we get empty custom components in safe mode.""" + hass.config.safe_mode = True + assert await loader.async_get_custom_components(hass) == {}