diff --git a/.coveragerc b/.coveragerc index b468e3ef746..0240e24b74a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -434,7 +434,13 @@ omit = homeassistant/components/luftdaten/* homeassistant/components/lupusec/* homeassistant/components/lutron/* - homeassistant/components/lutron_caseta/* + homeassistant/components/lutron_caseta/__init__.py + homeassistant/components/lutron_caseta/binary_sensor.py + homeassistant/components/lutron_caseta/cover.py + homeassistant/components/lutron_caseta/fan.py + homeassistant/components/lutron_caseta/light.py + homeassistant/components/lutron_caseta/scene.py + homeassistant/components/lutron_caseta/switch.py homeassistant/components/lw12wifi/light.py homeassistant/components/lyft/sensor.py homeassistant/components/magicseaweed/sensor.py @@ -727,6 +733,7 @@ omit = homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* homeassistant/components/stookalert/* + homeassistant/components/stream/* homeassistant/components/streamlabswater/* homeassistant/components/suez_water/* homeassistant/components/supervisord/sensor.py diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 4c46e7b3e7d..e5d8b03f316 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,6 +1,7 @@ """The airvisual component.""" import asyncio from datetime import timedelta +from math import ceil from pyairvisual import Client from pyairvisual.errors import AirVisualError, NodeProError @@ -37,7 +38,6 @@ from .const import ( PLATFORMS = ["air_quality", "sensor"] DEFAULT_ATTRIBUTION = "Data provided by AirVisual" -DEFAULT_GEOGRAPHY_SCAN_INTERVAL = timedelta(minutes=10) DEFAULT_NODE_PRO_SCAN_INTERVAL = timedelta(minutes=1) DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} @@ -88,6 +88,37 @@ def async_get_geography_id(geography_dict): ) +@callback +def async_get_cloud_api_update_interval(hass, api_key): + """Get a leveled scan interval for a particular cloud API key. + + This will shift based on the number of active consumers, thus keeping the user + under the monthly API limit. + """ + num_consumers = len( + { + config_entry + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.data.get(CONF_API_KEY) == api_key + } + ) + + # Assuming 10,000 calls per month and a "smallest possible month" of 28 days; note + # that we give a buffer of 1500 API calls for any drift, restarts, etc.: + minutes_between_api_calls = ceil(1 / (8500 / 28 / 24 / 60 / num_consumers)) + return timedelta(minutes=minutes_between_api_calls) + + +@callback +def async_reset_coordinator_update_intervals(hass, update_interval): + """Update any existing data coordinators with a new update interval.""" + if not hass.data[DOMAIN][DATA_COORDINATOR]: + return + + for coordinator in hass.data[DOMAIN][DATA_COORDINATOR].values(): + coordinator.update_interval = update_interval + + async def async_setup(hass, config): """Set up the AirVisual component.""" hass.data[DOMAIN] = {DATA_COORDINATOR: {}} @@ -163,6 +194,10 @@ async def async_setup_entry(hass, config_entry): client = Client(api_key=config_entry.data[CONF_API_KEY], session=websession) + update_interval = async_get_cloud_api_update_interval( + hass, config_entry.data[CONF_API_KEY] + ) + async def async_update_data(): """Get new data from the API.""" if CONF_CITY in config_entry.data: @@ -185,10 +220,14 @@ async def async_setup_entry(hass, config_entry): hass, LOGGER, name="geography data", - update_interval=DEFAULT_GEOGRAPHY_SCAN_INTERVAL, + update_interval=update_interval, update_method=async_update_data, ) + # Ensure any other, existing config entries that use this API key are updated + # with the new scan interval: + async_reset_coordinator_update_intervals(hass, update_interval) + # Only geography-based entries have options: config_entry.add_update_listener(async_update_options) else: diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 040b22945fd..573538f63bb 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging import re -from broadlink.exceptions import BroadlinkException, ReadError +from broadlink.exceptions import BroadlinkException, ReadError, StorageError import voluptuous as vol from homeassistant.const import CONF_HOST @@ -85,10 +85,11 @@ async def async_setup_service(hass, host, device): _LOGGER.info("Press the key you want Home Assistant to learn") start_time = utcnow() while (utcnow() - start_time) < timedelta(seconds=20): + await asyncio.sleep(1) try: packet = await device.async_request(device.api.check_data) - except ReadError: - await asyncio.sleep(1) + except (ReadError, StorageError): + continue except BroadlinkException as err_msg: _LOGGER.error("Failed to learn: %s", err_msg) return diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index b03bf7a4a04..03ecb9b7634 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -14,6 +14,7 @@ from broadlink.exceptions import ( BroadlinkException, DeviceOfflineError, ReadError, + StorageError, ) import voluptuous as vol @@ -321,10 +322,11 @@ class BroadlinkRemote(RemoteEntity): code = None start_time = utcnow() while (utcnow() - start_time) < timedelta(seconds=timeout): + await asyncio.sleep(1) try: code = await self.device.async_request(self.device.api.check_data) - except ReadError: - await asyncio.sleep(1) + except (ReadError, StorageError): + continue else: break diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index de5496cfd99..fcd6738b77c 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.34.2"], + "requirements": ["hass-nabucasa==0.34.3"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 9732962de5a..9b4e76e5eb1 100644 --- a/homeassistant/components/daikin/manifest.json +++ b/homeassistant/components/daikin/manifest.json @@ -3,7 +3,7 @@ "name": "Daikin AC", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/daikin", - "requirements": ["pydaikin==2.0.2"], + "requirements": ["pydaikin==2.0.4"], "codeowners": ["@fredrike"], "quality_scale": "platinum" } diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 01d1b6eb88f..89f83d8fa65 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20200519.1"], + "requirements": ["home-assistant-frontend==20200519.4"], "dependencies": [ "api", "auth", diff --git a/homeassistant/components/homekit/img_util.py b/homeassistant/components/homekit/img_util.py index 835b04558e6..88217bf776d 100644 --- a/homeassistant/components/homekit/img_util.py +++ b/homeassistant/components/homekit/img_util.py @@ -2,8 +2,6 @@ import logging -from turbojpeg import TurboJPEG - SUPPORTED_SCALING_FACTORS = [(7, 8), (3, 4), (5, 8), (1, 2), (3, 8), (1, 4), (1, 8)] _LOGGER = logging.getLogger(__name__) @@ -54,6 +52,12 @@ class TurboJPEGSingleton: def __init__(self): """Try to create TurboJPEG only once.""" try: + # TurboJPEG checks for libturbojpeg + # when its created, but it imports + # numpy which may or may not work so + # we have to guard the import here. + from turbojpeg import TurboJPEG # pylint: disable=import-outside-toplevel + TurboJPEGSingleton.__instance = TurboJPEG() except Exception: # pylint: disable=broad-except _LOGGER.exception( diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 605253e6235..d910de34321 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -89,10 +89,6 @@ class HKDevice: # mapped to a HA entity. self.entities = [] - # There are multiple entities sharing a single connection - only - # allow one entity to use pairing at once. - self.pairing_lock = asyncio.Lock() - self.available = True self.signal_state_updated = "_".join((DOMAIN, self.unique_id, "state_updated")) @@ -333,13 +329,11 @@ class HKDevice: async def get_characteristics(self, *args, **kwargs): """Read latest state from homekit accessory.""" - async with self.pairing_lock: - return await self.pairing.get_characteristics(*args, **kwargs) + return await self.pairing.get_characteristics(*args, **kwargs) async def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" - async with self.pairing_lock: - results = await self.pairing.put_characteristics(characteristics) + results = await self.pairing.put_characteristics(characteristics) # Feed characteristics back into HA and update the current state # results will only contain failures, so anythin in characteristics diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 07736f61c8e..961dd380ac1 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.37"], + "requirements": ["aiohomekit[IP]==0.2.38"], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] } diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index e14142677e3..8135b4a8c77 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -68,6 +68,12 @@ async def async_setup_entry(hass, entry, async_add_entities): except asyncio.TimeoutError: # Forced refresh is not required for setup pass + if ATTR_POSITION_DATA not in shade.raw_data: + _LOGGER.info( + "The %s shade was skipped because it is missing position data", + name_before_refresh, + ) + continue entities.append( PowerViewShade( shade, name_before_refresh, room_data, coordinator, device_info diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 3c98eeaf615..f89ca28023b 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -74,6 +74,17 @@ class ShadeEntity(HDEntity): @property def device_info(self): """Return the device_info of the device.""" + + device_info = { + "identifiers": {(DOMAIN, self._shade.id)}, + "name": self._shade_name, + "manufacturer": MANUFACTURER, + "via_device": (DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), + } + + if FIRMWARE_IN_SHADE not in self._shade.raw_data: + return device_info + firmware = self._shade.raw_data[FIRMWARE_IN_SHADE] sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" model = self._shade.raw_data[ATTR_TYPE] @@ -82,11 +93,6 @@ class ShadeEntity(HDEntity): model = shade.description break - return { - "identifiers": {(DOMAIN, self._shade.id)}, - "name": self._shade_name, - "model": str(model), - "sw_version": sw_version, - "manufacturer": MANUFACTURER, - "via_device": (DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), - } + device_info["sw_version"] = sw_version + device_info["model"] = model + return device_info diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 3128583f218..ba12d7ec8e2 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -85,12 +85,12 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): unique_id = user_input[CONF_UUID] = info[CONF_UUID] - if unique_id is None and info[CONF_SERIAL] is not None: + if not unique_id and info[CONF_SERIAL]: _LOGGER.debug( "Printer UUID is missing from IPP response. Falling back to IPP serial number" ) unique_id = info[CONF_SERIAL] - elif unique_id is None: + elif not unique_id: _LOGGER.debug("Unable to determine unique id from IPP response") await self.async_set_unique_id(unique_id) @@ -138,17 +138,17 @@ class IPPFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ipp_error") unique_id = self.discovery_info[CONF_UUID] - if unique_id is None and info[CONF_UUID] is not None: + if not unique_id and info[CONF_UUID]: _LOGGER.debug( "Printer UUID is missing from discovery info. Falling back to IPP UUID" ) unique_id = self.discovery_info[CONF_UUID] = info[CONF_UUID] - elif unique_id is None and info[CONF_SERIAL] is not None: + elif not unique_id and info[CONF_SERIAL]: _LOGGER.debug( "Printer UUID is missing from discovery info and IPP response. Falling back to IPP serial number" ) unique_id = info[CONF_SERIAL] - elif unique_id is None: + elif not unique_id: _LOGGER.debug( "Unable to determine unique id from discovery info and IPP response" ) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 59fd81e650e..ff7ec61ecc8 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -39,11 +39,7 @@ LUTRON_CASETA_COMPONENTS = ["light", "switch", "cover", "scene", "fan", "binary_ async def async_setup(hass, base_config): """Set up the Lutron component.""" - bridge_configs = base_config.get(DOMAIN) - - if not bridge_configs: - return True - + bridge_configs = base_config[DOMAIN] hass.data.setdefault(DOMAIN, {}) for config in bridge_configs: diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 45a7f10fbf0..3a5a4a151a1 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -94,11 +94,6 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await bridge.close() return True - except (KeyError, ValueError): - _LOGGER.error( - "Error while checking connectivity to bridge %s", self.data[CONF_HOST], - ) - return False except Exception: # pylint: disable=broad-except _LOGGER.exception( "Unknown exception while checking connectivity to bridge %s", diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index 7b55dfd9c87..34fc326425c 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -2,7 +2,10 @@ "domain": "lutron_caseta", "name": "Lutron Caséta", "documentation": "https://www.home-assistant.io/integrations/lutron_caseta", - "requirements": ["pylutron-caseta==0.6.1"], - "codeowners": ["@swails"], - "config_flow": true -} \ No newline at end of file + "requirements": [ + "pylutron-caseta==0.6.1" + ], + "codeowners": [ + "@swails" + ] +} diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 6d90c5828f9..bb8008e1fef 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -82,13 +82,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): if device.capabilities.events and await device.events.async_start(): platforms += ["binary_sensor", "sensor"] - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.events.async_stop) for component in platforms: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) ) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.async_stop) + return True diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index ceb861fc7dd..1dba697380d 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -219,7 +219,8 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): media_service = device.create_media_service() profiles = await media_service.GetProfiles() h264 = any( - profile.VideoEncoderConfiguration.Encoding == "H264" + profile.VideoEncoderConfiguration + and profile.VideoEncoderConfiguration.Encoding == "H264" for profile in profiles ) diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 0a35dadec26..938c960080f 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -8,7 +8,6 @@ from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedE import onvif from onvif import ONVIFCamera from onvif.exceptions import ONVIFError -from zeep.asyncio import AsyncTransport from zeep.exceptions import Fault from homeassistant.config_entries import ConfigEntry @@ -20,7 +19,6 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.util.dt as dt_util from .const import ( @@ -141,6 +139,12 @@ class ONVIFDevice: return True + async def async_stop(self, event=None): + """Shut it all down.""" + if self.events: + await self.events.async_stop() + await self.device.close() + async def async_check_date_and_time(self) -> None: """Warns if device and system date not synced.""" LOGGER.debug("Setting up the ONVIF device management service") @@ -251,7 +255,10 @@ class ONVIFDevice: profiles = [] for key, onvif_profile in enumerate(result): # Only add H264 profiles - if onvif_profile.VideoEncoderConfiguration.Encoding != "H264": + if ( + not onvif_profile.VideoEncoderConfiguration + or onvif_profile.VideoEncoderConfiguration.Encoding != "H264" + ): continue profile = Profile( @@ -278,9 +285,13 @@ class ONVIFDevice: is not None, ) - ptz_service = self.device.get_service("ptz") - presets = await ptz_service.GetPresets(profile.token) - profile.ptz.presets = [preset.token for preset in presets] + try: + ptz_service = self.device.create_ptz_service() + presets = await ptz_service.GetPresets(profile.token) + profile.ptz.presets = [preset.token for preset in presets] + except (Fault, ServerDisconnectedError): + # It's OK if Presets aren't supported + profile.ptz.presets = [] profiles.append(profile) @@ -326,7 +337,7 @@ class ONVIFDevice: LOGGER.warning("PTZ actions are not supported on device '%s'", self.name) return - ptz_service = self.device.get_service("ptz") + ptz_service = self.device.create_ptz_service() pan_val = distance * PAN_FACTOR.get(pan, 0) tilt_val = distance * TILT_FACTOR.get(tilt, 0) @@ -423,13 +434,11 @@ class ONVIFDevice: def get_device(hass, host, port, username, password) -> ONVIFCamera: """Get ONVIFCamera instance.""" - session = async_get_clientsession(hass) - transport = AsyncTransport(None, session=session) return ONVIFCamera( host, port, username, password, f"{os.path.dirname(onvif.__file__)}/wsdl/", - transport=transport, + no_cache=True, ) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 888fe5bd92b..183ad0ab532 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -91,7 +91,7 @@ class EventManager: return self.started - async def async_stop(self, event=None) -> None: + async def async_stop(self) -> None: """Unsubscribe from events.""" if not self._subscription: return @@ -110,7 +110,7 @@ class EventManager: async def async_pull_messages(self, _now: dt = None) -> None: """Pull messages from device.""" try: - pullpoint = self.device.get_service("pullpoint") + pullpoint = self.device.create_pullpoint_service() req = pullpoint.create_type("PullMessages") req.MessageLimit = 100 req.Timeout = dt.timedelta(seconds=60) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index f291f9c6613..4214cf3ab5c 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,7 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": ["onvif-zeep-async==0.3.0", "WSDiscovery==2.0.0"], + "requirements": ["onvif-zeep-async==0.4.0", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true diff --git a/homeassistant/components/ozw/manifest.json b/homeassistant/components/ozw/manifest.json index 3b828845852..c6c96ed15a2 100644 --- a/homeassistant/components/ozw/manifest.json +++ b/homeassistant/components/ozw/manifest.json @@ -3,7 +3,15 @@ "name": "OpenZWave (beta)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ozw", - "requirements": ["python-openzwave-mqtt==1.0.1"], - "after_dependencies": ["mqtt"], - "codeowners": ["@cgarwood", "@marcelveldt", "@MartinHjelmare"] + "requirements": [ + "python-openzwave-mqtt==1.0.2" + ], + "after_dependencies": [ + "mqtt" + ], + "codeowners": [ + "@cgarwood", + "@marcelveldt", + "@MartinHjelmare" + ] } diff --git a/homeassistant/components/prezzibenzina/sensor.py b/homeassistant/components/prezzibenzina/sensor.py index c985f96e6c6..f45d9d84669 100644 --- a/homeassistant/components/prezzibenzina/sensor.py +++ b/homeassistant/components/prezzibenzina/sensor.py @@ -42,7 +42,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the PrezziBenzina sensor platform.""" station = config[CONF_STATION] @@ -65,7 +65,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - async_add_entities(dev, True) + add_entities(dev, True) class PrezziBenzinaSensor(Entity): @@ -114,6 +114,6 @@ class PrezziBenzinaSensor(Entity): } return attrs - async def async_update(self): + def update(self): """Get the latest data and updates the states.""" self._data = self._client.get_by_id(self._station)[self._index] diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 62b3cc58fc8..57c64f4c64a 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -2,7 +2,7 @@ "domain": "roku", "name": "Roku", "documentation": "https://www.home-assistant.io/integrations/roku", - "requirements": ["rokuecp==0.4.0"], + "requirements": ["rokuecp==0.4.1"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 7b64888bbd1..8c92eff3687 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -7,6 +7,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -29,6 +30,7 @@ SUPPORT_ROKU = ( | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE + | SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON @@ -167,6 +169,14 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): """Turn off the Roku.""" await self.coordinator.roku.remote("poweroff") + async def async_media_pause(self) -> None: + """Send pause command.""" + await self.coordinator.roku.remote("play") + + async def async_media_play(self) -> None: + """Send play command.""" + await self.coordinator.roku.remote("play") + async def async_media_play_pause(self) -> None: """Send play/pause command.""" await self.coordinator.roku.remote("play") diff --git a/homeassistant/components/rpi_gpio_pwm/light.py b/homeassistant/components/rpi_gpio_pwm/light.py index 96ac3c6f2ed..f86d1f27832 100644 --- a/homeassistant/components/rpi_gpio_pwm/light.py +++ b/homeassistant/components/rpi_gpio_pwm/light.py @@ -17,7 +17,7 @@ from homeassistant.components.light import ( SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_TRANSITION, - Light, + LightEntity, ) from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_TYPE, STATE_ON import homeassistant.helpers.config_validation as cv @@ -104,7 +104,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(leds) -class PwmSimpleLed(Light, RestoreEntity): +class PwmSimpleLed(LightEntity, RestoreEntity): """Representation of a simple one-color PWM LED.""" def __init__(self, led, name): diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 9282c2cb855..153c006ccb2 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -79,8 +79,11 @@ class StreamOutput: @property def target_duration(self) -> int: """Return the average duration of the segments in seconds.""" + segment_length = len(self._segments) + if not segment_length: + return 0 durations = [s.duration for s in self._segments] - return round(sum(durations) // len(self._segments)) or 1 + return round(sum(durations) // segment_length) or 1 def get_segment(self, sequence: int = None) -> Any: """Retrieve a specific segment, or the whole list.""" diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 2cc60938a8d..e90d93cbfe3 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["av==7.0.1"], + "requirements": ["av==8.0.1"], "dependencies": ["http"], "codeowners": ["@hunterjm"], "quality_scale": "internal" diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 6cd07c7f926..15c1c3c02ff 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -164,3 +164,7 @@ def stream_worker(hass, stream, quit_event): # Assign the video packet to the new stream & mux packet.stream = buffer.vstream buffer.output.mux(packet) + + # Close stream + buffer.output.close() + container.close() diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json index 55149369427..7ad65b4abd4 100644 --- a/homeassistant/components/tellduslive/manifest.json +++ b/homeassistant/components/tellduslive/manifest.json @@ -3,7 +3,7 @@ "name": "Telldus Live", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tellduslive", - "requirements": ["tellduslive==0.10.10"], + "requirements": ["tellduslive==0.10.11"], "codeowners": ["@fredrike"], "quality_scale": "gold" } diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 644d35da728..a1b4327470f 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -40,7 +40,7 @@ def setup(hass, config): """Set up the Zabbix component.""" conf = config[DOMAIN] - protocol = "https" if config[CONF_SSL] else "http" + protocol = "https" if conf[CONF_SSL] else "http" url = urljoin(f"{protocol}://{conf[CONF_HOST]}", conf[CONF_PATH]) username = conf.get(CONF_USERNAME) diff --git a/homeassistant/const.py b/homeassistant/const.py index 366c2c79d99..8993225f283 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 110 -PATCH_VERSION = "1" +PATCH_VERSION = "2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 154fb024112..14ad0783380 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -83,7 +83,6 @@ FLOWS = [ "locative", "logi_circle", "luftdaten", - "lutron_caseta", "mailgun", "melcloud", "met", diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 8fbb81962ff..2e91d0d6622 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -399,17 +399,25 @@ def async_cleanup( ent_reg: "entity_registry.EntityRegistry", ) -> None: """Clean up device registry.""" - # Find all devices that are no longer referenced in the entity registry. - referenced = {entry.device_id for entry in ent_reg.entities.values()} - orphan = set(dev_reg.devices) - referenced + # Find all devices that are referenced by a config_entry. + config_entry_ids = {entry.entry_id for entry in hass.config_entries.async_entries()} + references_config_entries = { + device.id + for device in dev_reg.devices.values() + for config_entry_id in device.config_entries + if config_entry_id in config_entry_ids + } + + # Find all devices that are referenced in the entity registry. + references_entities = {entry.device_id for entry in ent_reg.entities.values()} + + orphan = set(dev_reg.devices) - references_entities - references_config_entries for dev_id in orphan: dev_reg.async_remove_device(dev_id) # Find all referenced config entries that no longer exist # This shouldn't happen but have not been able to track down the bug :( - config_entry_ids = {entry.entry_id for entry in hass.config_entries.async_entries()} - for device in list(dev_reg.devices.values()): for config_entry_id in device.config_entries: if config_entry_id not in config_entry_ids: diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index ce52d188540..af4bdb50fa4 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -431,7 +431,8 @@ async def entity_service_call(hass, platforms, func, call, required_features=Non # Skip entities that don't have the required feature. if required_features is not None and not any( - entity.supported_features & feature_set for feature_set in required_features + entity.supported_features & feature_set == feature_set + for feature_set in required_features ): continue diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d356e62a32e..e8c3550e996 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,8 +11,8 @@ ciso8601==2.1.3 cryptography==2.9.2 defusedxml==0.6.0 distro==1.5.0 -hass-nabucasa==0.34.2 -home-assistant-frontend==20200519.1 +hass-nabucasa==0.34.3 +home-assistant-frontend==20200519.4 importlib-metadata==1.6.0 jinja2>=2.11.1 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0b8d15022b4..3a7bc962114 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -178,7 +178,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.37 +aiohomekit[IP]==0.2.38 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -291,7 +291,7 @@ atenpdu==0.3.0 aurorapy==0.2.6 # homeassistant.components.stream -av==7.0.1 +av==8.0.1 # homeassistant.components.avea avea==1.4 @@ -701,7 +701,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.2 +hass-nabucasa==0.34.3 # homeassistant.components.mqtt hbmqtt==0.9.5 @@ -731,7 +731,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200519.1 +home-assistant-frontend==20200519.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -994,7 +994,7 @@ oemthermostat==1.1 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==0.3.0 +onvif-zeep-async==0.4.0 # homeassistant.components.opengarage open-garage==0.1.4 @@ -1263,7 +1263,7 @@ pycsspeechtts==1.0.3 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.0.2 +pydaikin==2.0.4 # homeassistant.components.danfoss_air pydanfossair==0.1.0 @@ -1711,7 +1711,7 @@ python-nest==4.1.0 python-nmap==0.6.1 # homeassistant.components.ozw -python-openzwave-mqtt==1.0.1 +python-openzwave-mqtt==1.0.2 # homeassistant.components.qbittorrent python-qbittorrent==0.4.1 @@ -1871,7 +1871,7 @@ rjpl==0.3.5 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.4.0 +rokuecp==0.4.1 # homeassistant.components.roomba roombapy==1.6.1 @@ -2063,7 +2063,7 @@ tellcore-net==0.4 tellcore-py==1.1.2 # homeassistant.components.tellduslive -tellduslive==0.10.10 +tellduslive==0.10.11 # homeassistant.components.lg_soundbar temescal==0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 570a59cc780..59a9b17b4a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -82,7 +82,7 @@ aiofreepybox==0.0.8 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.37 +aiohomekit[IP]==0.2.38 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -138,7 +138,7 @@ arcam-fmj==0.4.4 async-upnp-client==0.14.13 # homeassistant.components.stream -av==7.0.1 +av==8.0.1 # homeassistant.components.axis axis==25 @@ -294,7 +294,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.34.2 +hass-nabucasa==0.34.3 # homeassistant.components.mqtt hbmqtt==0.9.5 @@ -312,7 +312,7 @@ hole==0.5.1 holidays==0.10.2 # homeassistant.components.frontend -home-assistant-frontend==20200519.1 +home-assistant-frontend==20200519.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -414,7 +414,7 @@ numpy==1.18.4 oauth2client==4.0.0 # homeassistant.components.onvif -onvif-zeep-async==0.3.0 +onvif-zeep-async==0.4.0 # homeassistant.components.openerz openerz-api==0.1.0 @@ -533,7 +533,7 @@ pychromecast==5.1.0 pycoolmasternet==0.0.4 # homeassistant.components.daikin -pydaikin==2.0.2 +pydaikin==2.0.4 # homeassistant.components.deconz pydeconz==70 @@ -705,7 +705,7 @@ python-miio==0.5.0.1 python-nest==4.1.0 # homeassistant.components.ozw -python-openzwave-mqtt==1.0.1 +python-openzwave-mqtt==1.0.2 # homeassistant.components.songpal python-songpal==0.12 @@ -762,7 +762,7 @@ rflink==0.0.52 ring_doorbell==0.6.0 # homeassistant.components.roku -rokuecp==0.4.0 +rokuecp==0.4.1 # homeassistant.components.roomba roombapy==1.6.1 @@ -826,7 +826,7 @@ stringcase==1.2.0 sunwatcher==0.2.1 # homeassistant.components.tellduslive -tellduslive==0.10.10 +tellduslive==0.10.11 # homeassistant.components.powerwall tesla-powerwall==0.2.8 diff --git a/tests/components/homekit/test_img_util.py b/tests/components/homekit/test_img_util.py index 4ada89b3acd..728bb8847ff 100644 --- a/tests/components/homekit/test_img_util.py +++ b/tests/components/homekit/test_img_util.py @@ -23,25 +23,19 @@ def test_scale_jpeg_camera_image(): camera_image = Image("image/jpeg", EMPTY_16_12_JPEG) turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) - with patch( - "homeassistant.components.homekit.img_util.TurboJPEG", return_value=False - ): + with patch("turbojpeg.TurboJPEG", return_value=False): TurboJPEGSingleton() assert scale_jpeg_camera_image(camera_image, 16, 12) == camera_image.content turbo_jpeg = mock_turbo_jpeg(first_width=16, first_height=12) - with patch( - "homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg - ): + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() assert scale_jpeg_camera_image(camera_image, 16, 12) == EMPTY_16_12_JPEG turbo_jpeg = mock_turbo_jpeg( first_width=16, first_height=12, second_width=8, second_height=6 ) - with patch( - "homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg - ): + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() jpeg_bytes = scale_jpeg_camera_image(camera_image, 8, 6) @@ -51,12 +45,10 @@ def test_scale_jpeg_camera_image(): def test_turbojpeg_load_failure(): """Handle libjpegturbo not being installed.""" - with patch( - "homeassistant.components.homekit.img_util.TurboJPEG", side_effect=Exception - ): + with patch("turbojpeg.TurboJPEG", side_effect=Exception): TurboJPEGSingleton() assert TurboJPEGSingleton.instance() is False - with patch("homeassistant.components.homekit.img_util.TurboJPEG"): + with patch("turbojpeg.TurboJPEG"): TurboJPEGSingleton() assert TurboJPEGSingleton.instance() diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index e3444ca23e4..0c002fa7213 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -193,9 +193,7 @@ async def test_camera_stream_source_configured(hass, run_driver, events): turbo_jpeg = mock_turbo_jpeg( first_width=16, first_height=12, second_width=300, second_height=200 ) - with patch( - "homeassistant.components.homekit.img_util.TurboJPEG", return_value=turbo_jpeg - ): + with patch("turbojpeg.TurboJPEG", return_value=turbo_jpeg): TurboJPEGSingleton() assert await hass.async_add_executor_job( acc.get_snapshot, {"aid": 2, "image-width": 300, "image-height": 200} diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 0093ba57e5b..a468115f239 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -264,6 +264,24 @@ async def test_zeroconf_with_uuid_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_empty_unique_id_required_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow if printer lacks (empty) unique identification.""" + mock_connection(aioclient_mock, no_unique_id=True) + + discovery_info = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO, + "properties": {**MOCK_ZEROCONF_IPP_SERVICE_INFO["properties"], "UUID": ""}, + } + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unique_id_required" + + async def test_zeroconf_unique_id_required_abort( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index a528e223e44..fc9c5fe279d 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,5 +1,4 @@ """Test the Lutron Caseta config flow.""" -from asynctest import patch from pylutron_caseta.smartbridge import Smartbridge from homeassistant import config_entries, data_entry_flow @@ -14,6 +13,7 @@ from homeassistant.components.lutron_caseta.const import ( ) from homeassistant.const import CONF_HOST +from tests.async_mock import AsyncMock, patch from tests.common import MockConfigEntry @@ -51,7 +51,11 @@ async def test_bridge_import_flow(hass): with patch( "homeassistant.components.lutron_caseta.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch.object(Smartbridge, "create_tls") as create_tls: + ) as mock_setup_entry, patch( + "homeassistant.components.lutron_caseta.async_setup", return_value=True + ), patch.object( + Smartbridge, "create_tls" + ) as create_tls: create_tls.return_value = MockBridge(can_connect=True) result = await hass.config_entries.flow.async_init( @@ -77,9 +81,7 @@ async def test_bridge_cannot_connect(hass): CONF_CA_CERTS: "", } - with patch( - "homeassistant.components.lutron_caseta.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch.object(Smartbridge, "create_tls") as create_tls: + with patch.object(Smartbridge, "create_tls") as create_tls: create_tls.return_value = MockBridge(can_connect=False) result = await hass.config_entries.flow.async_init( @@ -91,8 +93,41 @@ async def test_bridge_cannot_connect(hass): assert result["type"] == "form" assert result["step_id"] == STEP_IMPORT_FAILED assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} - # validate setup_entry was not called - assert len(mock_setup_entry.mock_calls) == 0 + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == CasetaConfigFlow.ABORT_REASON_CANNOT_CONNECT + + +async def test_bridge_cannot_connect_unknown_error(hass): + """Test checking for connection and encountering an unknown error.""" + + entry_mock_data = { + CONF_HOST: "", + CONF_KEYFILE: "", + CONF_CERTFILE: "", + CONF_CA_CERTS: "", + } + + with patch.object(Smartbridge, "create_tls") as create_tls: + mock_bridge = MockBridge() + mock_bridge.connect = AsyncMock(side_effect=Exception()) + create_tls.return_value = mock_bridge + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=entry_mock_data, + ) + + assert result["type"] == "form" + assert result["step_id"] == STEP_IMPORT_FAILED + assert result["errors"] == {"base": ERROR_CANNOT_CONNECT} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == CasetaConfigFlow.ABORT_REASON_CANNOT_CONNECT async def test_duplicate_bridge_import(hass): diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index f91a8b286b3..9d809cae433 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player.const import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -30,6 +31,8 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_TURN_OFF, @@ -142,6 +145,7 @@ async def test_supported_features( | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE + | SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON @@ -170,6 +174,7 @@ async def test_tv_supported_features( | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE + | SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA | SUPPORT_TURN_ON @@ -267,6 +272,26 @@ async def test_services( remote_mock.assert_called_once_with("poweron") + with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) + + remote_mock.assert_called_once_with("play") + + with patch("homeassistant.components.roku.Roku.remote") as remote_mock: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID}, + blocking=True, + ) + + remote_mock.assert_called_once_with("play") + with patch("homeassistant.components.roku.Roku.remote") as remote_mock: await hass.services.async_call( MP_DOMAIN, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index ef5f92de79c..3fbb73a2aa8 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -539,7 +539,7 @@ async def test_cleanup_device_registry(hass, registry): device_registry.async_cleanup(hass, registry, ent_reg) assert registry.async_get_device({("hue", "d1")}, set()) is not None - assert registry.async_get_device({("hue", "d2")}, set()) is None + assert registry.async_get_device({("hue", "d2")}, set()) is not None assert registry.async_get_device({("hue", "d3")}, set()) is not None assert registry.async_get_device({("something", "d4")}, set()) is None diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e87fd2646dd..ba72cbc83ca 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -35,6 +35,10 @@ from tests.common import ( mock_service, ) +SUPPORT_A = 1 +SUPPORT_B = 2 +SUPPORT_C = 4 + @pytest.fixture def mock_handle_entity_call(): @@ -52,17 +56,31 @@ def mock_entities(hass): entity_id="light.kitchen", available=True, should_poll=False, - supported_features=1, + supported_features=SUPPORT_A, ) living_room = MockEntity( entity_id="light.living_room", available=True, should_poll=False, - supported_features=0, + supported_features=SUPPORT_B, + ) + bedroom = MockEntity( + entity_id="light.bedroom", + available=True, + should_poll=False, + supported_features=(SUPPORT_A | SUPPORT_B), + ) + bathroom = MockEntity( + entity_id="light.bathroom", + available=True, + should_poll=False, + supported_features=(SUPPORT_B | SUPPORT_C), ) entities = OrderedDict() entities[kitchen.entity_id] = kitchen entities[living_room.entity_id] = living_room + entities[bedroom.entity_id] = bedroom + entities[bathroom.entity_id] = bathroom return entities @@ -307,18 +325,61 @@ async def test_async_get_all_descriptions(hass): async def test_call_with_required_features(hass, mock_entities): - """Test service calls invoked only if entity has required feautres.""" + """Test service calls invoked only if entity has required features.""" test_service_mock = AsyncMock(return_value=None) await service.entity_service_call( hass, [Mock(entities=mock_entities)], test_service_mock, ha.ServiceCall("test_domain", "test_service", {"entity_id": "all"}), - required_features=[1], + required_features=[SUPPORT_A], ) - assert len(mock_entities) == 2 - # Called once because only one of the entities had the required features + + assert test_service_mock.call_count == 2 + expected = [ + mock_entities["light.kitchen"], + mock_entities["light.bedroom"], + ] + actual = [call[0][0] for call in test_service_mock.call_args_list] + assert all(entity in actual for entity in expected) + + +async def test_call_with_both_required_features(hass, mock_entities): + """Test service calls invoked only if entity has both features.""" + test_service_mock = AsyncMock(return_value=None) + await service.entity_service_call( + hass, + [Mock(entities=mock_entities)], + test_service_mock, + ha.ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + required_features=[SUPPORT_A | SUPPORT_B], + ) + assert test_service_mock.call_count == 1 + assert [call[0][0] for call in test_service_mock.call_args_list] == [ + mock_entities["light.bedroom"] + ] + + +async def test_call_with_one_of_required_features(hass, mock_entities): + """Test service calls invoked with one entity having the required features.""" + test_service_mock = AsyncMock(return_value=None) + await service.entity_service_call( + hass, + [Mock(entities=mock_entities)], + test_service_mock, + ha.ServiceCall("test_domain", "test_service", {"entity_id": "all"}), + required_features=[SUPPORT_A, SUPPORT_C], + ) + + assert test_service_mock.call_count == 3 + expected = [ + mock_entities["light.kitchen"], + mock_entities["light.bedroom"], + mock_entities["light.bathroom"], + ] + actual = [call[0][0] for call in test_service_mock.call_args_list] + assert all(entity in actual for entity in expected) async def test_call_with_sync_func(hass, mock_entities): @@ -458,7 +519,7 @@ async def test_call_no_context_target_all(hass, mock_handle_entity_call, mock_en ), ) - assert len(mock_handle_entity_call.mock_calls) == 2 + assert len(mock_handle_entity_call.mock_calls) == 4 assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list( mock_entities.values() ) @@ -494,7 +555,7 @@ async def test_call_with_match_all( ha.ServiceCall("test_domain", "test_service", {"entity_id": "all"}), ) - assert len(mock_handle_entity_call.mock_calls) == 2 + assert len(mock_handle_entity_call.mock_calls) == 4 assert [call[1][1] for call in mock_handle_entity_call.mock_calls] == list( mock_entities.values() )