diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index f9fcec6aa6a..5fecbce3ded 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -28,6 +28,7 @@ TYPE_BATT6 = "batt6" TYPE_BATT7 = "batt7" TYPE_BATT8 = "batt8" TYPE_BATT9 = "batt9" +TYPE_BATTIN = "battin" TYPE_BATTOUT = "battout" TYPE_BATT_CO2 = "batt_co2" TYPE_BATT_LIGHTNING = "batt_lightning" @@ -140,6 +141,13 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, on_state=0, ), + AmbientBinarySensorDescription( + key=TYPE_BATTIN, + name="Interior Battery", + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + on_state=0, + ), AmbientBinarySensorDescription( key=TYPE_BATT10, name="Soil Monitor Battery 10", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index eee9919e711..15fc4a7f648 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -108,7 +108,7 @@ class BackupManager: size=round(backup_path.stat().st_size / 1_048_576, 2), ) backups[backup.slug] = backup - except (OSError, TarError, json.JSONDecodeError) as err: + except (OSError, TarError, json.JSONDecodeError, KeyError) as err: LOGGER.warning("Unable to read backup %s: %s", backup_path, err) return backups diff --git a/homeassistant/components/dlna_dmr/config_flow.py b/homeassistant/components/dlna_dmr/config_flow.py index e8999c3ebb6..ac7e3c83253 100644 --- a/homeassistant/components/dlna_dmr/config_flow.py +++ b/homeassistant/components/dlna_dmr/config_flow.py @@ -134,10 +134,16 @@ class DlnaDmrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dmr") - discovery_service_ids = { - service.get("serviceId") - for service in discovery_service_list.get("service") or [] - } + + services = discovery_service_list.get("service") + if not services: + discovery_service_ids: set[str] = set() + elif isinstance(services, list): + discovery_service_ids = {service.get("serviceId") for service in services} + else: + # Only one service defined (etree_to_dict failed to make a list) + discovery_service_ids = {services.get("serviceId")} + if not DmrDevice.SERVICE_IDS.issubset(discovery_service_ids): return self.async_abort(reason="not_dmr") diff --git a/homeassistant/components/dlna_dms/config_flow.py b/homeassistant/components/dlna_dms/config_flow.py index bf310f7b234..8cb34be927f 100644 --- a/homeassistant/components/dlna_dms/config_flow.py +++ b/homeassistant/components/dlna_dms/config_flow.py @@ -77,10 +77,16 @@ class DlnaDmsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): discovery_service_list = discovery_info.upnp.get(ssdp.ATTR_UPNP_SERVICE_LIST) if not discovery_service_list: return self.async_abort(reason="not_dms") - discovery_service_ids = { - service.get("serviceId") - for service in discovery_service_list.get("service") or [] - } + + services = discovery_service_list.get("service") + if not services: + discovery_service_ids: set[str] = set() + elif isinstance(services, list): + discovery_service_ids = {service.get("serviceId") for service in services} + else: + # Only one service defined (etree_to_dict failed to make a list) + discovery_service_ids = {services.get("serviceId")} + if not DmsDevice.SERVICE_IDS.issubset(discovery_service_ids): return self.async_abort(reason="not_dms") diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 7d3841dcf47..0bb94f2b832 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -2,7 +2,7 @@ "domain": "generic", "name": "Generic Camera", "config_flow": true, - "requirements": ["av==8.1.0", "pillow==9.0.1"], + "requirements": ["ha-av==9.1.1-3", "pillow==9.0.1"], "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": ["@davet2001"], "iot_class": "local_push" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index fb9f70f1417..c6bec04123c 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -90,7 +90,7 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Return True if entity is available.""" return ( super().available - and DATA_KEY_OS in self.coordinator.data + and DATA_KEY_SUPERVISOR in self.coordinator.data and self.entity_description.key in self.coordinator.data[DATA_KEY_SUPERVISOR] ) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index bce3f5ece61..f3c98e618b8 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -312,7 +312,6 @@ class Dishwasher( """Dishwasher class.""" PROGRAMS = [ - {"name": "Dishcare.Dishwasher.Program.PreRinse"}, {"name": "Dishcare.Dishwasher.Program.Auto1"}, {"name": "Dishcare.Dishwasher.Program.Auto2"}, {"name": "Dishcare.Dishwasher.Program.Auto3"}, diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 91c1c47d47e..5efd0915cb0 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -63,7 +63,7 @@ class HomeKitSmokeSensor(HomeKitEntity, BinarySensorEntity): class HomeKitCarbonMonoxideSensor(HomeKitEntity, BinarySensorEntity): """Representation of a Homekit BO sensor.""" - _attr_device_class = BinarySensorDeviceClass.GAS + _attr_device_class = BinarySensorDeviceClass.CO def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity is tracking.""" diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index d41eb0ed220..c3bdfcc42ae 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -293,7 +293,10 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._abort_if_unique_id_configured(updates=updated_ip_port) for progress in self._async_in_progress(include_uninitialized=True): - if progress["context"].get("unique_id") == normalized_hkid: + context = progress["context"] + if context.get("unique_id") == normalized_hkid and not context.get( + "pairing" + ): if paired: # If the device gets paired, we want to dismiss # an existing discovery since we can no longer @@ -350,6 +353,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self._async_setup_controller() if pair_info and self.finish_pairing: + self.context["pairing"] = True code = pair_info["pairing_code"] try: code = ensure_pin_format( diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index fec2cfb3d0a..c9f0742ea57 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -1,4 +1,6 @@ """Provides the constants needed for component.""" +from enum import IntEnum + # How long our auth signature on the content should be valid for CONTENT_AUTH_EXPIRY_TIME = 3600 * 24 @@ -90,6 +92,32 @@ REPEAT_MODE_OFF = "off" REPEAT_MODE_ONE = "one" REPEAT_MODES = [REPEAT_MODE_OFF, REPEAT_MODE_ALL, REPEAT_MODE_ONE] + +class MediaPlayerEntityFeature(IntEnum): + """Supported features of the media player entity.""" + + PAUSE = 1 + SEEK = 2 + VOLUME_SET = 4 + VOLUME_MUTE = 8 + PREVIOUS_TRACK = 16 + NEXT_TRACK = 32 + + TURN_ON = 128 + TURN_OFF = 256 + PLAY_MEDIA = 512 + VOLUME_STEP = 1024 + SELECT_SOURCE = 2048 + STOP = 4096 + CLEAR_PLAYLIST = 8192 + PLAY = 16384 + SHUFFLE_SET = 32768 + SELECT_SOUND_MODE = 65536 + BROWSE_MEDIA = 131072 + REPEAT_SET = 262144 + GROUPING = 524288 + + SUPPORT_PAUSE = 1 SUPPORT_SEEK = 2 SUPPORT_VOLUME_SET = 4 diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 79688130a36..47d365ff75a 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -6,6 +6,7 @@ from collections.abc import Iterable from typing import Any from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, @@ -33,6 +34,7 @@ from .const import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + MediaPlayerEntityFeature, ) # mypy: allow-untyped-defs @@ -46,6 +48,8 @@ async def _async_reproduce_states( reproduce_options: dict[str, Any] | None = None, ) -> None: """Reproduce component states.""" + cur_state = hass.states.get(state.entity_id) + features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 async def call_service(service: str, keys: Iterable) -> None: """Call service with set of attributes given.""" @@ -59,28 +63,48 @@ async def _async_reproduce_states( ) if state.state == STATE_OFF: - await call_service(SERVICE_TURN_OFF, []) + if features & MediaPlayerEntityFeature.TURN_OFF: + await call_service(SERVICE_TURN_OFF, []) # entities that are off have no other attributes to restore return - if state.state in ( - STATE_ON, - STATE_PLAYING, - STATE_IDLE, - STATE_PAUSED, + if ( + state.state + in ( + STATE_ON, + STATE_PLAYING, + STATE_IDLE, + STATE_PAUSED, + ) + and features & MediaPlayerEntityFeature.TURN_ON ): await call_service(SERVICE_TURN_ON, []) - if ATTR_MEDIA_VOLUME_LEVEL in state.attributes: + cur_state = hass.states.get(state.entity_id) + features = cur_state.attributes[ATTR_SUPPORTED_FEATURES] if cur_state else 0 + + if ( + ATTR_MEDIA_VOLUME_LEVEL in state.attributes + and features & MediaPlayerEntityFeature.VOLUME_SET + ): await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL]) - if ATTR_MEDIA_VOLUME_MUTED in state.attributes: + if ( + ATTR_MEDIA_VOLUME_MUTED in state.attributes + and features & MediaPlayerEntityFeature.VOLUME_MUTE + ): await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED]) - if ATTR_INPUT_SOURCE in state.attributes: + if ( + ATTR_INPUT_SOURCE in state.attributes + and features & MediaPlayerEntityFeature.SELECT_SOURCE + ): await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE]) - if ATTR_SOUND_MODE in state.attributes: + if ( + ATTR_SOUND_MODE in state.attributes + and features & MediaPlayerEntityFeature.SELECT_SOUND_MODE + ): await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE]) already_playing = False @@ -88,18 +112,25 @@ async def _async_reproduce_states( if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and ( ATTR_MEDIA_CONTENT_ID in state.attributes ): - await call_service( - SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE], - ) + if features & MediaPlayerEntityFeature.PLAY_MEDIA: + await call_service( + SERVICE_PLAY_MEDIA, + [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE], + ) already_playing = True - if state.state == STATE_PLAYING and not already_playing: + if ( + not already_playing + and state.state == STATE_PLAYING + and features & MediaPlayerEntityFeature.PLAY + ): await call_service(SERVICE_MEDIA_PLAY, []) elif state.state == STATE_IDLE: - await call_service(SERVICE_MEDIA_STOP, []) + if features & MediaPlayerEntityFeature.STOP: + await call_service(SERVICE_MEDIA_STOP, []) elif state.state == STATE_PAUSED: - await call_service(SERVICE_MEDIA_PAUSE, []) + if features & MediaPlayerEntityFeature.PAUSE: + await call_service(SERVICE_MEDIA_PAUSE, []) async def async_reproduce_states( diff --git a/homeassistant/components/openhome/manifest.json b/homeassistant/components/openhome/manifest.json index 6b8815f9318..01dafe6f3f2 100644 --- a/homeassistant/components/openhome/manifest.json +++ b/homeassistant/components/openhome/manifest.json @@ -2,7 +2,7 @@ "domain": "openhome", "name": "Linn / OpenHome", "documentation": "https://www.home-assistant.io/integrations/openhome", - "requirements": ["openhomedevice==2.0.1"], + "requirements": ["openhomedevice==2.0.2"], "codeowners": ["@bazwilliams"], "iot_class": "local_polling", "loggers": ["async_upnp_client", "openhomedevice"] diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index b773687c18c..1defc1624f1 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -51,6 +51,7 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self.contract = contract self._auth = auth + self._attr_code_arm_required = False self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract self._attr_supported_features = SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 556294409d6..7ab12d1a76a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -224,9 +224,7 @@ class SamsungTVDevice(MediaPlayerEntity): startup_tasks.append(self._async_startup_app_list()) if self._dmr_device and not self._dmr_device.is_subscribed: - startup_tasks.append( - self._dmr_device.async_subscribe_services(auto_resubscribe=True) - ) + startup_tasks.append(self._async_resubscribe_dmr()) if not self._dmr_device and self._ssdp_rendering_control_location: startup_tasks.append(self._async_startup_dmr()) @@ -284,7 +282,7 @@ class SamsungTVDevice(MediaPlayerEntity): # NETWORK,NONE upnp_factory = UpnpFactory(upnp_requester, non_strict=True) upnp_device: UpnpDevice | None = None - with contextlib.suppress(UpnpConnectionError): + with contextlib.suppress(UpnpConnectionError, UpnpResponseError): upnp_device = await upnp_factory.async_create_device( self._ssdp_rendering_control_location ) @@ -319,6 +317,11 @@ class SamsungTVDevice(MediaPlayerEntity): LOGGER.debug("Error while subscribing during device connect: %r", err) raise + async def _async_resubscribe_dmr(self) -> None: + assert self._dmr_device + with contextlib.suppress(UpnpConnectionError): + await self._dmr_device.async_subscribe_services(auto_resubscribe=True) + async def _async_shutdown_dmr(self) -> None: """Handle removal.""" if (dmr_device := self._dmr_device) is not None: diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index d8fd035a5ab..38194d60b3a 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": ["PyTurboJPEG==1.6.6", "av==8.1.0"], + "requirements": ["PyTurboJPEG==1.6.6", "ha-av==9.1.1-3"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], "quality_scale": "internal", diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index f55ea4c16da..8074949a888 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -202,6 +202,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_API_KEY], entry.data[CONF_LOCATION][CONF_LATITUDE], entry.data[CONF_LOCATION][CONF_LONGITUDE], + unit_system="metric", session=async_get_clientsession(hass), ) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 8361869c18a..d221922df54 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -31,16 +31,14 @@ from homeassistant.const import ( LENGTH_MILES, PERCENTAGE, PRESSURE_HPA, - PRESSURE_INHG, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, - TEMP_FAHRENHEIT, + TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.distance import convert as distance_convert -from homeassistant.util.pressure import convert as pressure_convert from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( @@ -80,7 +78,7 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): unit_imperial: str | None = None unit_metric: str | None = None multiplication_factor: Callable[[float], float] | float | None = None - metric_conversion: Callable[[float], float] | float | None = None + imperial_conversion: Callable[[float], float] | float | None = None value_map: Any | None = None def __post_init__(self) -> None: @@ -105,13 +103,13 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key=TMRW_ATTR_FEELS_LIKE, name="Feels Like", - native_unit_of_measurement=TEMP_FAHRENHEIT, + native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_DEW_POINT, name="Dew Point", - native_unit_of_measurement=TEMP_FAHRENHEIT, + native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), # Data comes in as inHg @@ -119,9 +117,6 @@ SENSOR_TYPES = ( key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=PRESSURE_HPA, - multiplication_factor=lambda val: pressure_convert( - val, PRESSURE_INHG, PRESSURE_HPA - ), device_class=SensorDeviceClass.PRESSURE, ), # Data comes in as BTUs/(hr * ft^2) @@ -131,7 +126,7 @@ SENSOR_TYPES = ( name="Global Horizontal Irradiance", unit_imperial=IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, - metric_conversion=3.15459, + imperial_conversion=(1 / 3.15459), ), # Data comes in as miles TomorrowioSensorEntityDescription( @@ -139,8 +134,8 @@ SENSOR_TYPES = ( name="Cloud Base", unit_imperial=LENGTH_MILES, unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS + imperial_conversion=lambda val: distance_convert( + val, LENGTH_KILOMETERS, LENGTH_MILES ), ), # Data comes in as miles @@ -149,8 +144,8 @@ SENSOR_TYPES = ( name="Cloud Ceiling", unit_imperial=LENGTH_MILES, unit_metric=LENGTH_KILOMETERS, - metric_conversion=lambda val: distance_convert( - val, LENGTH_MILES, LENGTH_KILOMETERS + imperial_conversion=lambda val: distance_convert( + val, LENGTH_KILOMETERS, LENGTH_MILES ), ), TomorrowioSensorEntityDescription( @@ -164,8 +159,10 @@ SENSOR_TYPES = ( name="Wind Gust", unit_imperial=SPEED_MILES_PER_HOUR, unit_metric=SPEED_METERS_PER_SECOND, - metric_conversion=lambda val: distance_convert(val, LENGTH_MILES, LENGTH_METERS) - / 3600, + imperial_conversion=lambda val: distance_convert( + val, LENGTH_METERS, LENGTH_MILES + ) + * 3600, ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRECIPITATION_TYPE, @@ -183,20 +180,16 @@ SENSOR_TYPES = ( multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, ), - # Data comes in as ug/ft^3 TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_25, name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - multiplication_factor=3.2808399**3, device_class=SensorDeviceClass.PM25, ), - # Data comes in as ug/ft^3 TomorrowioSensorEntityDescription( key=TMRW_ATTR_PARTICULATE_MATTER_10, name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - multiplication_factor=3.2808399**3, device_class=SensorDeviceClass.PM10, ), # Data comes in as ppb @@ -360,15 +353,15 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): if desc.multiplication_factor is not None: state = handle_conversion(state, desc.multiplication_factor) - # If an imperial unit isn't provided, we always want to convert to metric since - # that is what the UI expects + # If there is an imperial conversion needed and the instance is using imperial, + # apply the conversion logic. if ( - desc.metric_conversion + desc.imperial_conversion and desc.unit_imperial is not None and desc.unit_imperial != desc.unit_metric - and self.hass.config.units.is_metric + and not self.hass.config.units.is_metric ): - return handle_conversion(state, desc.metric_conversion) + return handle_conversion(state, desc.imperial_conversion) return state diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 1a775723b0b..ec8dab241de 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -29,6 +29,7 @@ KEYS_TO_REDACT = { CONF_UNIQUE_ID, "network_key", CONF_NWK_EXTENDED_PAN_ID, + "partner_ieee", } @@ -50,7 +51,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict: """Return diagnostics for a config entry.""" - config: dict = hass.data[DATA_ZHA][DATA_ZHA_CONFIG] + config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] return async_redact_data( { diff --git a/homeassistant/const.py b/homeassistant/const.py index ee1e7a65731..8fa0b529c04 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/requirements_all.txt b/requirements_all.txt index 79bab40278a..a5b62691256 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -347,10 +347,6 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.6 -# homeassistant.components.generic -# homeassistant.components.stream -av==8.1.0 - # homeassistant.components.avea # avea==1.5.1 @@ -766,6 +762,10 @@ gstreamer-player==1.1.2 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==9.1.1-3 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 @@ -1141,7 +1141,7 @@ openerz-api==0.1.0 openevsewifi==1.1.0 # homeassistant.components.openhome -openhomedevice==2.0.1 +openhomedevice==2.0.2 # homeassistant.components.opensensemap opensensemap-api==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6de908fd699..c669f014c39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -277,10 +277,6 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.6 -# homeassistant.components.generic -# homeassistant.components.stream -av==8.1.0 - # homeassistant.components.axis axis==44 @@ -536,6 +532,10 @@ growattServer==1.1.0 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==9.1.1-3 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 diff --git a/setup.cfg b/setup.cfg index b391172bd19..e0103f4657e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = homeassistant -version = 2022.4.3 +version = 2022.4.4 author = The Home Assistant Authors author_email = hello@home-assistant.io license = Apache-2.0 diff --git a/tests/components/dlna_dmr/test_config_flow.py b/tests/components/dlna_dmr/test_config_flow.py index 44ab4ea313f..6112c7c5ed5 100644 --- a/tests/components/dlna_dmr/test_config_flow.py +++ b/tests/components/dlna_dmr/test_config_flow.py @@ -388,7 +388,7 @@ async def test_ssdp_flow_upnp_udn( async def test_ssdp_missing_services(hass: HomeAssistant) -> None: """Test SSDP ignores devices that are missing required services.""" - # No services defined at all + # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] @@ -400,6 +400,18 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_dmr" + # Service list does not contain services + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + # AVTransport service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = discovery.upnp.copy() @@ -417,6 +429,28 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: assert result["reason"] == "not_dmr" +async def test_ssdp_single_service(hass: HomeAssistant) -> None: + """Test SSDP discovery info with only one service defined. + + THe etree_to_dict function turns multiple services into a list of dicts, but + a single service into only a dict. + """ + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = discovery.upnp.copy() + service_list = discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST].copy() + # Turn mock's list of service dicts into a single dict + service_list["service"] = service_list["service"][0] + discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + + result = await hass.config_entries.flow.async_init( + DLNA_DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dmr" + + async def test_ssdp_ignore_device(hass: HomeAssistant) -> None: """Test SSDP discovery ignores certain devices.""" discovery = dataclasses.replace(MOCK_DISCOVERY) diff --git a/tests/components/dlna_dms/test_config_flow.py b/tests/components/dlna_dms/test_config_flow.py index 521c3169aa5..591457b23c6 100644 --- a/tests/components/dlna_dms/test_config_flow.py +++ b/tests/components/dlna_dms/test_config_flow.py @@ -325,7 +325,7 @@ async def test_ssdp_flow_upnp_udn( async def test_ssdp_missing_services(hass: HomeAssistant) -> None: """Test SSDP ignores devices that are missing required services.""" - # No services defined at all + # No service list at all discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) del discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] @@ -337,6 +337,18 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_dms" + # Service list does not contain services + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = dict(discovery.upnp) + discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = {"bad_key": "bad_value"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dms" + # ContentDirectory service is missing discovery = dataclasses.replace(MOCK_DISCOVERY) discovery.upnp = dict(discovery.upnp) @@ -352,3 +364,25 @@ async def test_ssdp_missing_services(hass: HomeAssistant) -> None: ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "not_dms" + + +async def test_ssdp_single_service(hass: HomeAssistant) -> None: + """Test SSDP discovery info with only one service defined. + + THe etree_to_dict function turns multiple services into a list of dicts, but + a single service into only a dict. + """ + discovery = dataclasses.replace(MOCK_DISCOVERY) + discovery.upnp = dict(discovery.upnp) + service_list = dict(discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST]) + # Turn mock's list of service dicts into a single dict + service_list["service"] = service_list["service"][0] + discovery.upnp[ssdp.ATTR_UPNP_SERVICE_LIST] = service_list + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "not_dms" diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index d83beb07df3..ff9877b2e44 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -114,7 +114,7 @@ async def test_carbon_monoxide_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" - assert state.attributes["device_class"] == BinarySensorDeviceClass.GAS + assert state.attributes["device_class"] == BinarySensorDeviceClass.CO def create_occupancy_sensor_service(accessory): diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index a65d63b1af2..3b106dab186 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for homekit_controller config flow.""" +import asyncio from unittest import mock import unittest.mock from unittest.mock import AsyncMock, patch @@ -14,6 +15,7 @@ from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY from homeassistant.helpers import device_registry from tests.common import MockConfigEntry, mock_device_registry @@ -133,7 +135,7 @@ def get_flow_context(hass, result): def get_device_discovery_info( - device, upper_case_props=False, missing_csharp=False + device, upper_case_props=False, missing_csharp=False, paired=False ) -> zeroconf.ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" result = zeroconf.ZeroconfServiceInfo( @@ -150,7 +152,7 @@ def get_device_discovery_info( "s#": device.description.state_num, "ff": "0", "ci": "0", - "sf": "1", + "sf": "0" if paired else "1", "sh": "", }, type="_hap._tcp.local.", @@ -250,10 +252,8 @@ async def test_abort_duplicate_flow(hass, controller): async def test_pair_already_paired_1(hass, controller): """Already paired.""" device = setup_mock_accessory(controller) - discovery_info = get_device_discovery_info(device) - # Flag device as already paired - discovery_info.properties["sf"] = 0x0 + discovery_info = get_device_discovery_info(device, paired=True) # Device is discovered result = await hass.config_entries.flow.async_init( @@ -692,6 +692,7 @@ async def test_pair_form_errors_on_finish(hass, controller, exception, expected) "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", "source": config_entries.SOURCE_ZEROCONF, + "pairing": True, } @@ -883,3 +884,69 @@ async def test_discovery_dismiss_existing_flow_on_paired(hass, controller): len(hass.config_entries.flow.async_progress_by_handler("homekit_controller")) == 0 ) + + +async def test_mdns_update_to_paired_during_pairing(hass, controller): + """Test we do not abort pairing if mdns is updated to reflect paired during pairing.""" + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + discovery_info_paired = get_device_discovery_info(device, paired=True) + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert get_flow_context(hass, result) == { + "title_placeholders": {"name": "TestDevice"}, + "unique_id": "00:00:00:00:00:00", + "source": config_entries.SOURCE_ZEROCONF, + } + + mdns_update_to_paired = asyncio.Event() + + original_async_start_pairing = device.async_start_pairing + + async def _async_start_pairing(*args, **kwargs): + finish_pairing = await original_async_start_pairing(*args, **kwargs) + + async def _finish_pairing(*args, **kwargs): + # Insert an event wait to make sure + # we trigger the mdns update in the middle of the pairing + await mdns_update_to_paired.wait() + return await finish_pairing(*args, **kwargs) + + return _finish_pairing + + with patch.object(device, "async_start_pairing", _async_start_pairing): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == "form" + assert get_flow_context(hass, result) == { + "title_placeholders": {"name": "TestDevice"}, + "unique_id": "00:00:00:00:00:00", + "source": config_entries.SOURCE_ZEROCONF, + } + + # User enters pairing code + task = asyncio.create_task( + hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"pairing_code": "111-22-333"} + ) + ) + # Make sure when the device is discovered as paired via mdns + # it does not abort pairing if it happens before pairing is finished + result2 = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info_paired, + ) + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_paired" + mdns_update_to_paired.set() + result = await task + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Koogeek-LS1-20833F" + assert result["data"] == {} diff --git a/tests/components/media_player/test_reproduce_state.py b/tests/components/media_player/test_reproduce_state.py index ba0072bc2f8..c2e454d1a04 100644 --- a/tests/components/media_player/test_reproduce_state.py +++ b/tests/components/media_player/test_reproduce_state.py @@ -14,9 +14,11 @@ from homeassistant.components.media_player.const import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, + MediaPlayerEntityFeature, ) from homeassistant.components.media_player.reproduce_state import async_reproduce_states from homeassistant.const import ( + ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, @@ -39,31 +41,47 @@ ENTITY_2 = "media_player.test2" @pytest.mark.parametrize( - "service,state", + "service,state,supported_feature", [ - (SERVICE_TURN_ON, STATE_ON), - (SERVICE_TURN_OFF, STATE_OFF), - (SERVICE_MEDIA_PLAY, STATE_PLAYING), - (SERVICE_MEDIA_STOP, STATE_IDLE), - (SERVICE_MEDIA_PAUSE, STATE_PAUSED), + (SERVICE_TURN_ON, STATE_ON, MediaPlayerEntityFeature.TURN_ON), + (SERVICE_TURN_OFF, STATE_OFF, MediaPlayerEntityFeature.TURN_OFF), + (SERVICE_MEDIA_PLAY, STATE_PLAYING, MediaPlayerEntityFeature.PLAY), + (SERVICE_MEDIA_STOP, STATE_IDLE, MediaPlayerEntityFeature.STOP), + (SERVICE_MEDIA_PAUSE, STATE_PAUSED, MediaPlayerEntityFeature.PAUSE), ], ) -async def test_state(hass, service, state): +async def test_state(hass, service, state, supported_feature): """Test that we can turn a state into a service call.""" calls_1 = async_mock_service(hass, DOMAIN, service) if service != SERVICE_TURN_ON: async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) + # Don't support the feature won't call the service + hass.states.async_set(ENTITY_1, "something", {ATTR_SUPPORTED_FEATURES: 0}) await async_reproduce_states(hass, [State(ENTITY_1, state)]) await hass.async_block_till_done() + assert len(calls_1) == 0 + hass.states.async_set( + ENTITY_1, "something", {ATTR_SUPPORTED_FEATURES: supported_feature} + ) + await async_reproduce_states(hass, [State(ENTITY_1, state)]) assert len(calls_1) == 1 assert calls_1[0].data == {"entity_id": ENTITY_1} async def test_turn_on_with_mode(hass): """Test that state with additional attributes call multiple services.""" + hass.states.async_set( + ENTITY_1, + "something", + { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + }, + ) + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) calls_2 = async_mock_service(hass, DOMAIN, SERVICE_SELECT_SOUND_MODE) @@ -82,6 +100,13 @@ async def test_turn_on_with_mode(hass): async def test_multiple_same_state(hass): """Test that multiple states with same state gets calls.""" + for entity in ENTITY_1, ENTITY_2: + hass.states.async_set( + entity, + "something", + {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON}, + ) + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) await async_reproduce_states(hass, [State(ENTITY_1, "on"), State(ENTITY_2, "on")]) @@ -96,6 +121,16 @@ async def test_multiple_same_state(hass): async def test_multiple_different_state(hass): """Test that multiple states with different state gets calls.""" + for entity in ENTITY_1, ENTITY_2: + hass.states.async_set( + entity, + "something", + { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + }, + ) + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) @@ -111,6 +146,12 @@ async def test_multiple_different_state(hass): async def test_state_with_context(hass): """Test that context is forwarded.""" + hass.states.async_set( + ENTITY_1, + "something", + {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON}, + ) + calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) context = Context() @@ -126,6 +167,16 @@ async def test_state_with_context(hass): async def test_attribute_no_state(hass): """Test that no state service call is made with none state.""" + hass.states.async_set( + ENTITY_1, + "something", + { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOUND_MODE + }, + ) + calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON) calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF) calls_3 = async_mock_service(hass, DOMAIN, SERVICE_SELECT_SOUND_MODE) @@ -145,16 +196,38 @@ async def test_attribute_no_state(hass): @pytest.mark.parametrize( - "service,attribute", + "service,attribute,supported_feature", [ - (SERVICE_VOLUME_SET, ATTR_MEDIA_VOLUME_LEVEL), - (SERVICE_VOLUME_MUTE, ATTR_MEDIA_VOLUME_MUTED), - (SERVICE_SELECT_SOURCE, ATTR_INPUT_SOURCE), - (SERVICE_SELECT_SOUND_MODE, ATTR_SOUND_MODE), + ( + SERVICE_VOLUME_SET, + ATTR_MEDIA_VOLUME_LEVEL, + MediaPlayerEntityFeature.VOLUME_SET, + ), + ( + SERVICE_VOLUME_MUTE, + ATTR_MEDIA_VOLUME_MUTED, + MediaPlayerEntityFeature.VOLUME_MUTE, + ), + ( + SERVICE_SELECT_SOURCE, + ATTR_INPUT_SOURCE, + MediaPlayerEntityFeature.SELECT_SOURCE, + ), + ( + SERVICE_SELECT_SOUND_MODE, + ATTR_SOUND_MODE, + MediaPlayerEntityFeature.SELECT_SOUND_MODE, + ), ], ) -async def test_attribute(hass, service, attribute): +async def test_attribute(hass, service, attribute, supported_feature): """Test that service call is made for each attribute.""" + hass.states.async_set( + ENTITY_1, + "something", + {ATTR_SUPPORTED_FEATURES: supported_feature}, + ) + calls_1 = async_mock_service(hass, DOMAIN, service) value = "dummy" @@ -168,7 +241,12 @@ async def test_attribute(hass, service, attribute): async def test_play_media(hass): - """Test that no state service call is made with none state.""" + """Test playing media.""" + hass.states.async_set( + ENTITY_1, + "something", + {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.PLAY_MEDIA}, + ) calls_1 = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) value_1 = "dummy_1" diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 91a4a2eabc5..ef025204ea6 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -146,8 +146,8 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, CO, "0.0") check_sensor_state(hass, NO2, "20.08") check_sensor_state(hass, SO2, "4.32") - check_sensor_state(hass, PM25, "5.3") - check_sensor_state(hass, PM10, "20.13") + check_sensor_state(hass, PM25, "0.15") + check_sensor_state(hass, PM10, "0.57") check_sensor_state(hass, MEP_AQI, "23") check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") @@ -158,14 +158,14 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, GRASS_POLLEN, "none") check_sensor_state(hass, WEED_POLLEN, "none") check_sensor_state(hass, TREE_POLLEN, "none") - check_sensor_state(hass, FEELS_LIKE, "38.5") - check_sensor_state(hass, DEW_POINT, "22.68") - check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.97") - check_sensor_state(hass, GHI, "0.0") - check_sensor_state(hass, CLOUD_BASE, "1.19") + check_sensor_state(hass, FEELS_LIKE, "101.3") + check_sensor_state(hass, DEW_POINT, "72.82") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "29.47") + check_sensor_state(hass, GHI, "0") + check_sensor_state(hass, CLOUD_BASE, "0.74") check_sensor_state(hass, CLOUD_COVER, "100") - check_sensor_state(hass, CLOUD_CEILING, "1.19") - check_sensor_state(hass, WIND_GUST, "5.65") + check_sensor_state(hass, CLOUD_CEILING, "0.74") + check_sensor_state(hass, WIND_GUST, "12.64") check_sensor_state(hass, PRECIPITATION_TYPE, "rain")