diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index 3011e0602c9a45..18052f0c6ccb9a 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["airgradient==0.9.2"], + "requirements": ["airgradient==0.10.0"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py index e7221e15415bdf..45544495c0bf7b 100644 --- a/homeassistant/components/airgradient/number.py +++ b/homeassistant/components/airgradient/number.py @@ -29,7 +29,7 @@ class AirGradientNumberEntityDescription(NumberEntityDescription): """Describes AirGradient number entity.""" - value_fn: Callable[[Config], int] + value_fn: Callable[[Config], int | None] set_value_fn: Callable[[AirGradientClient, int], Awaitable[None]] diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py index f6b29e5b5fa3dd..c350b5f744fd26 100644 --- a/homeassistant/components/airgradient/select.py +++ b/homeassistant/components/airgradient/select.py @@ -64,7 +64,9 @@ class AirGradientSelectEntityDescription(SelectEntityDescription): translation_key="display_pm_standard", options=list(PM_STANDARD_REVERSE), entity_category=EntityCategory.CONFIG, - value_fn=lambda config: PM_STANDARD.get(config.pm_standard), + value_fn=lambda config: ( + PM_STANDARD.get(config.pm_standard) if config.pm_standard else None + ), set_value_fn=lambda client, value: client.set_pm_standard( PM_STANDARD_REVERSE[value] ), @@ -100,7 +102,7 @@ class AirGradientSelectEntityDescription(SelectEntityDescription): ] -def _get_value(value: int, values: list[str]) -> str | None: +def _get_value(value: int | None, values: list[str]) -> str | None: str_value = str(value) return str_value if str_value in values else None diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py index 848494424da7af..e07682aabdf6fb 100644 --- a/homeassistant/components/airgradient/sensor.py +++ b/homeassistant/components/airgradient/sensor.py @@ -210,7 +210,9 @@ class AirGradientConfigSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, options=list(PM_STANDARD_REVERSE), entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda config: PM_STANDARD.get(config.pm_standard), + value_fn=lambda config: ( + PM_STANDARD.get(config.pm_standard) if config.pm_standard else None + ), ), AirGradientConfigSensorEntityDescription( key="display_brightness", diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py index 1411a4e556d41a..3ce6510230d0f1 100644 --- a/homeassistant/components/airgradient/switch.py +++ b/homeassistant/components/airgradient/switch.py @@ -29,7 +29,7 @@ class AirGradientSwitchEntityDescription(SwitchEntityDescription): """Describes AirGradient switch entity.""" - value_fn: Callable[[Config], bool] + value_fn: Callable[[Config], bool | None] set_value_fn: Callable[[AirGradientClient, bool], Awaitable[None]] @@ -98,7 +98,7 @@ def __init__( @property @override - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the state of the switch.""" return self.entity_description.value_fn(self.coordinator.data.config) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index d1d18ae48ba49b..4c6f9f7ad57b1a 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -10,7 +10,6 @@ fan, humidifier, input_number, - light, media_player, number, remote, @@ -20,29 +19,64 @@ ) from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, + AlarmControlPanelEntityStateAttribute, AlarmControlPanelState, CodeFormat, ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, HVACMode -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN -from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityCapabilityAttribute, + ClimateEntityStateAttribute, + HVACMode, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverEntityStateAttribute, +) +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntityCapabilityAttribute, + FanEntityStateAttribute, +) +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + HumidifierEntityCapabilityAttribute, + HumidifierEntityStateAttribute, +) from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN +from homeassistant.components.light import LightEntityStateAttribute from homeassistant.components.lock import LockState -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.components.media_player import ( + MediaPlayerEntityCapabilityAttribute, + MediaPlayerEntityStateAttribute, +) +from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, + NumberEntityCapabilityAttribute, +) +from homeassistant.components.remote import ( + DOMAIN as REMOTE_DOMAIN, + RemoteEntityStateAttribute, +) from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN -from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntityCapabilityAttribute, + VacuumEntityStateAttribute, +) +from homeassistant.components.valve import ( + DOMAIN as VALVE_DOMAIN, + ValveEntityStateAttribute, +) +from homeassistant.components.water_heater import ( + DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterCapabilityAttribute, + WaterHeaterStateAttribute, +) from homeassistant.const import ( - ATTR_CODE_FORMAT, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, - ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_IDLE, STATE_OFF, @@ -51,6 +85,7 @@ STATE_PLAYING, STATE_UNAVAILABLE, STATE_UNKNOWN, + EntityStateAttribute, UnitOfLength, UnitOfMass, UnitOfTemperature, @@ -103,7 +138,9 @@ def get_resource_by_unit_of_measurement(entity: State) -> str: """Translate the unit of measurement to an Alexa Global Catalog keyword.""" - unit: str = entity.attributes.get("unit_of_measurement", "preset") + unit: str = entity.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT, "preset" + ) return UNIT_TO_CATALOG_TAG.get(unit, AlexaGlobalCatalog.SETTING_PRESET) @@ -619,7 +656,9 @@ def get_property(self, name: str) -> Any: """Read and return a property.""" if name != "brightness": raise UnsupportedProperty(name) - if brightness := self.entity.attributes.get("brightness"): + if brightness := self.entity.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ): return round(brightness / 255.0 * 100) return 0 @@ -676,9 +715,17 @@ def get_property(self, name: str) -> Any: raise UnsupportedProperty(name) hue_saturation: tuple[float, float] | None - if (hue_saturation := self.entity.attributes.get(light.ATTR_HS_COLOR)) is None: + if ( + hue_saturation := self.entity.attributes.get( + LightEntityStateAttribute.HS_COLOR + ) + ) is None: hue_saturation = (0, 0) - if (brightness := self.entity.attributes.get(light.ATTR_BRIGHTNESS)) is None: + if ( + brightness := self.entity.attributes.get( + LightEntityStateAttribute.BRIGHTNESS + ) + ) is None: brightness = 0 return { @@ -774,7 +821,9 @@ def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" properties = [{"name": "volume"}] - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & media_player.MediaPlayerEntityFeature.VOLUME_MUTE: properties.append({"name": "muted"}) @@ -795,14 +844,16 @@ def get_property(self, name: str) -> Any: """Read and return a property.""" if name == "volume": current_level = self.entity.attributes.get( - media_player.ATTR_MEDIA_VOLUME_LEVEL + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL ) if current_level is not None: return round(float(current_level) * 100) if name == "muted": return bool( - self.entity.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) + self.entity.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_MUTED + ) ) return None @@ -871,7 +922,9 @@ def supported_operations(self) -> list[str]: Supported Operations: FastForward, Next, Pause, Play, Previous, Rewind, StartOver, Stop """ - supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported_features = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) operations: dict[ cover.CoverEntityFeature | media_player.MediaPlayerEntityFeature, str @@ -929,7 +982,10 @@ def name(self) -> str: def inputs(self) -> list[dict[str, str]] | None: """Return the list of valid supported inputs.""" source_list: list[Any] = ( - self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] + self.entity.attributes.get( + MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST + ) + or [] ) return AlexaInputController.get_valid_inputs(source_list) @@ -1008,15 +1064,20 @@ def get_property(self, name: str) -> Any: raise UnsupportedProperty(name) unit: str = self.entity.attributes.get( - ATTR_UNIT_OF_MEASUREMENT, self.hass.config.units.temperature_unit + EntityStateAttribute.UNIT_OF_MEASUREMENT, + self.hass.config.units.temperature_unit, ) temp: str | None = self.entity.state if self.entity.domain == CLIMATE_DOMAIN: unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) + temp = self.entity.attributes.get( + ClimateEntityStateAttribute.CURRENT_TEMPERATURE + ) elif self.entity.domain == WATER_HEATER_DOMAIN: unit = self.hass.config.units.temperature_unit - temp = self.entity.attributes.get(water_heater.ATTR_CURRENT_TEMPERATURE) + temp = self.entity.attributes.get( + WaterHeaterStateAttribute.CURRENT_TEMPERATURE + ) if temp is None or temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): return None @@ -1197,7 +1258,9 @@ def name(self) -> str: def properties_supported(self) -> list[dict[str, str]]: """Return what properties this entity supports.""" properties = [{"name": "thermostatMode"}] - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if self.entity.domain == CLIMATE_DOMAIN: if supported & climate.ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: properties.append({"name": "lowerSetpoint"}) @@ -1230,7 +1293,7 @@ def get_property(self, name: str) -> Any: if name == "thermostatMode": if self.entity.domain == WATER_HEATER_DOMAIN: return None - preset = self.entity.attributes.get(climate.ATTR_PRESET_MODE) + preset = self.entity.attributes.get(ClimateEntityStateAttribute.PRESET_MODE) mode: dict[str, str] | str | None if preset in API_THERMOSTAT_PRESETS: @@ -1251,11 +1314,17 @@ def get_property(self, name: str) -> Any: unit = self.hass.config.units.temperature_unit if name == "targetSetpoint": - temp = self.entity.attributes.get(ATTR_TEMPERATURE) + temp = self.entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMPERATURE + ) elif name == "lowerSetpoint": - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + temp = self.entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMP_LOW + ) elif name == "upperSetpoint": - temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) + temp = self.entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMP_HIGH + ) else: raise UnsupportedProperty(name) @@ -1285,14 +1354,19 @@ def configuration(self) -> dict[str, Any] | None: if self.entity.domain == WATER_HEATER_DOMAIN: return None - hvac_modes = self.entity.attributes.get(climate.ATTR_HVAC_MODES) or [] + hvac_modes = ( + self.entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES) + or [] + ) supported_modes: list[str] = [ API_THERMOSTAT_MODES[mode] for mode in hvac_modes if mode in API_THERMOSTAT_MODES ] - preset_modes = self.entity.attributes.get(climate.ATTR_PRESET_MODES) + preset_modes = self.entity.attributes.get( + ClimateEntityCapabilityAttribute.PRESET_MODES + ) if preset_modes: for mode in preset_modes: thermostat_mode = API_THERMOSTAT_PRESETS.get(mode) @@ -1426,8 +1500,10 @@ def get_property(self, name: str) -> Any: @override def configuration(self) -> dict[str, Any] | None: """Return configuration object with supported authorization types.""" - code_format = self.entity.attributes.get(ATTR_CODE_FORMAT) - supported = self.entity.attributes[ATTR_SUPPORTED_FEATURES] + code_format = self.entity.attributes.get( + AlarmControlPanelEntityStateAttribute.CODE_FORMAT + ) + supported = self.entity.attributes[EntityStateAttribute.SUPPORTED_FEATURES] configuration = {} supported_arm_states = [{"value": "DISARMED"}] @@ -1518,38 +1594,50 @@ def get_property(self, name: str) -> Any: # Fan Direction if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_DIRECTION}": - mode = self.entity.attributes.get(fan.ATTR_DIRECTION, None) + mode = self.entity.attributes.get(FanEntityStateAttribute.DIRECTION, None) if mode in (fan.DIRECTION_FORWARD, fan.DIRECTION_REVERSE, STATE_UNKNOWN): return f"{fan.ATTR_DIRECTION}.{mode}" # Fan preset_mode if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}": - mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None) - if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()): + mode = self.entity.attributes.get(FanEntityStateAttribute.PRESET_MODE, None) + if mode in self.entity.attributes.get( + FanEntityCapabilityAttribute.PRESET_MODES, () + ): return f"{fan.ATTR_PRESET_MODE}.{mode}" # Humidifier mode if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}": - mode = self.entity.attributes.get(humidifier.ATTR_MODE) + mode = self.entity.attributes.get(HumidifierEntityStateAttribute.MODE) modes: list[str] = ( - self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or [] + self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.AVAILABLE_MODES + ) + or [] ) if mode in modes: return f"{humidifier.ATTR_MODE}.{mode}" # Remote Activity if self.instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}": - activity = self.entity.attributes.get(remote.ATTR_CURRENT_ACTIVITY, None) - if activity in self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST, []): + activity = self.entity.attributes.get( + RemoteEntityStateAttribute.CURRENT_ACTIVITY, None + ) + if activity in self.entity.attributes.get( + RemoteEntityStateAttribute.ACTIVITY_LIST, [] + ): return f"{remote.ATTR_ACTIVITY}.{activity}" # Water heater operation mode if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": operation_mode = self.entity.attributes.get( - water_heater.ATTR_OPERATION_MODE + WaterHeaterStateAttribute.OPERATION_MODE ) operation_modes: list[str] = ( - self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or [] + self.entity.attributes.get( + WaterHeaterCapabilityAttribute.OPERATION_LIST + ) + or [] ) if operation_mode in operation_modes: return f"{water_heater.ATTR_OPERATION_MODE}.{operation_mode}" @@ -1612,7 +1700,10 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: self._resource = AlexaModeResource( [AlexaGlobalCatalog.SETTING_PRESET], False ) - preset_modes = self.entity.attributes.get(fan.ATTR_PRESET_MODES) or [] + preset_modes = ( + self.entity.attributes.get(FanEntityCapabilityAttribute.PRESET_MODES) + or [] + ) for preset_mode in preset_modes: self._resource.add_mode( f"{fan.ATTR_PRESET_MODE}.{preset_mode}", [preset_mode] @@ -1628,7 +1719,12 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Humidifier modes if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}": self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) - modes = self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) or [] + modes = ( + self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.AVAILABLE_MODES + ) + or [] + ) for mode in modes: self._resource.add_mode(f"{humidifier.ATTR_MODE}.{mode}", [mode]) # Humidifiers or Fans with a single mode completely break Alexa discovery, @@ -1643,7 +1739,10 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: if self.instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) operation_modes = ( - self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) or [] + self.entity.attributes.get( + WaterHeaterCapabilityAttribute.OPERATION_LIST + ) + or [] ) for operation_mode in operation_modes: self._resource.add_mode( @@ -1664,7 +1763,10 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Use the mode controller for a remote because the input controller # only allows a preset of names as an input. self._resource = AlexaModeResource([AlexaGlobalCatalog.SETTING_MODE], False) - activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] + activities = ( + self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST) + or [] + ) for activity in activities: self._resource.add_mode( f"{remote.ATTR_ACTIVITY}.{activity}", [activity] @@ -1698,7 +1800,9 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Valve position resources if self.instance == f"{VALVE_DOMAIN}.state": - supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported_features = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) self._resource = AlexaModeResource( ["Preset", AlexaGlobalCatalog.SETTING_PRESET], False ) @@ -1727,7 +1831,9 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @override def semantics(self) -> dict[str, Any] | None: """Build and return semantics object.""" - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) # Cover Position if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}": @@ -1871,24 +1977,32 @@ def get_property(self, name: str) -> Any: # Cover Position if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}": - return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION) + return self.entity.attributes.get( + CoverEntityStateAttribute.CURRENT_POSITION + ) # Cover Tilt if self.instance == f"{COVER_DOMAIN}.tilt": - return self.entity.attributes.get(cover.ATTR_CURRENT_TILT_POSITION) + return self.entity.attributes.get( + CoverEntityStateAttribute.CURRENT_TILT_POSITION + ) # Fan speed percentage if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}": - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported and fan.FanEntityFeature.SET_SPEED: - return self.entity.attributes.get(fan.ATTR_PERCENTAGE) + return self.entity.attributes.get(FanEntityStateAttribute.PERCENTAGE) return 100 if self.entity.state == fan.STATE_ON else 0 # Humidifier target humidity if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}": # If the humidifier is turned off the target humidity attribute is not set. # We return 0 to make clear we do not know the current value. - return self.entity.attributes.get(humidifier.ATTR_HUMIDITY, 0) + return self.entity.attributes.get( + HumidifierEntityStateAttribute.HUMIDITY, 0 + ) # Input Number Value if self.instance == f"{INPUT_NUMBER_DOMAIN}.{input_number.ATTR_VALUE}": @@ -1900,14 +2014,18 @@ def get_property(self, name: str) -> Any: # Vacuum Fan Speed if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}": - speed_list = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED_LIST) - speed = self.entity.attributes.get(vacuum.ATTR_FAN_SPEED) + speed_list = self.entity.attributes.get( + VacuumEntityCapabilityAttribute.FAN_SPEED_LIST + ) + speed = self.entity.attributes.get(VacuumEntityStateAttribute.FAN_SPEED) if speed_list is not None and speed is not None: return next((i for i, v in enumerate(speed_list) if v == speed), None) # Valve Position if self.instance == f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}": - return self.entity.attributes.get(valve.ATTR_CURRENT_POSITION) + return self.entity.attributes.get( + ValveEntityStateAttribute.CURRENT_POSITION + ) return None @@ -1925,7 +2043,9 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Fan Speed Percentage Resources if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}": - percentage_step = self.entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) + percentage_step = self.entity.attributes.get( + FanEntityStateAttribute.PERCENTAGE_STEP + ) self._resource = AlexaPresetResource( labels=["Percentage", AlexaGlobalCatalog.SETTING_FAN_SPEED], min_value=0, @@ -1941,8 +2061,12 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: if self.instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_HUMIDITY}": self._resource = AlexaPresetResource( labels=["Humidity", "Percentage", "Target humidity"], - min_value=self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10), - max_value=self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90), + min_value=self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10 + ), + max_value=self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90 + ), precision=1, unit=AlexaGlobalCatalog.UNIT_PERCENT, ) @@ -1975,7 +2099,7 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: min_value = float(self.entity.attributes[input_number.ATTR_MIN]) max_value = float(self.entity.attributes[input_number.ATTR_MAX]) precision = float(self.entity.attributes.get(input_number.ATTR_STEP, 1)) - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + unit = self.entity.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) self._resource = AlexaPresetResource( ["Value", get_resource_by_unit_of_measurement(self.entity)], @@ -1994,10 +2118,16 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Number Value if self.instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}": - min_value = float(self.entity.attributes[number.ATTR_MIN]) - max_value = float(self.entity.attributes[number.ATTR_MAX]) - precision = float(self.entity.attributes.get(number.ATTR_STEP, 1)) - unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + min_value = float( + self.entity.attributes[NumberEntityCapabilityAttribute.MIN] + ) + max_value = float( + self.entity.attributes[NumberEntityCapabilityAttribute.MAX] + ) + precision = float( + self.entity.attributes.get(NumberEntityCapabilityAttribute.STEP, 1) + ) + unit = self.entity.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) self._resource = AlexaPresetResource( ["Value", get_resource_by_unit_of_measurement(self.entity)], @@ -2016,7 +2146,9 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: # Vacuum Fan Speed Resources if self.instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}": - speed_list = self.entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed_list = self.entity.attributes[ + VacuumEntityCapabilityAttribute.FAN_SPEED_LIST + ] max_value = len(speed_list) - 1 self._resource = AlexaPresetResource( labels=[AlexaGlobalCatalog.SETTING_FAN_SPEED], @@ -2050,7 +2182,9 @@ def capability_resources(self) -> dict[str, list[dict[str, Any]]]: @override def semantics(self) -> dict[str, Any] | None: """Build and return semantics object.""" - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) # Cover Position if self.instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}": @@ -2111,8 +2245,12 @@ def semantics(self) -> dict[str, Any] | None: lower_labels = [AlexaSemantics.ACTION_LOWER] raise_labels = [AlexaSemantics.ACTION_RAISE] self._semantics = AlexaSemantics() - min_value = self.entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10) - max_value = self.entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90) + min_value = self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10 + ) + max_value = self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90 + ) self._semantics.add_action_to_directive( lower_labels, "SetRangeValue", {"rangeValue": min_value} @@ -2217,7 +2355,9 @@ def get_property(self, name: str) -> Any: # Fan Oscillating if self.instance == f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}": - is_on = bool(self.entity.attributes.get(fan.ATTR_OSCILLATING)) + is_on = bool( + self.entity.attributes.get(FanEntityStateAttribute.OSCILLATING) + ) return "ON" if is_on else "OFF" # Stop Valve @@ -2531,7 +2671,9 @@ def get_property(self, name: str) -> Any: if name != "mode": raise UnsupportedProperty(name) - sound_mode = self.entity.attributes.get(media_player.ATTR_SOUND_MODE) + sound_mode = self.entity.attributes.get( + MediaPlayerEntityStateAttribute.SOUND_MODE + ) if sound_mode and sound_mode.upper() in self.VALID_SOUND_MODES: return sound_mode.upper() @@ -2542,7 +2684,10 @@ def configurations(self) -> dict[str, Any] | None: """Return the sound modes supported in the configurations object.""" configurations = None supported_sound_modes = self.get_valid_inputs( - self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] + self.entity.attributes.get( + MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST + ) + or [] ) if supported_sound_modes: configurations = {"modes": {"supported": supported_sound_modes}} diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index f434f07c29dfbd..c18e88e292bb91 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -22,27 +22,46 @@ ) from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, + AlarmControlPanelEntityStateAttribute, ) from homeassistant.components.alert import DOMAIN as ALERT_DOMAIN from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityCapabilityAttribute, +) from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.components.event import DOMAIN as EVENT_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntityCapabilityAttribute, +) from homeassistant.components.group import DOMAIN as GROUP_DOMAIN -from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + HumidifierEntityCapabilityAttribute, +) from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN -from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, + LightEntityCapabilityAttribute, +) from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerEntityCapabilityAttribute, +) from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.components.remote import ( + DOMAIN as REMOTE_DOMAIN, + RemoteEntityStateAttribute, +) from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -50,13 +69,14 @@ from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.components.water_heater import ( + DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterCapabilityAttribute, +) from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_SUPPORTED_FEATURES, - ATTR_UNIT_OF_MEASUREMENT, CONF_DESCRIPTION, CONF_NAME, + EntityStateAttribute, UnitOfTemperature, __version__, ) @@ -444,7 +464,7 @@ def default_display_categories(self) -> list[str]: if self.entity.domain == INPUT_BOOLEAN_DOMAIN: return [DisplayCategory.OTHER] - device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class == switch.SwitchDeviceClass.OUTLET: return [DisplayCategory.SMARTPLUG] @@ -494,12 +514,19 @@ def default_display_categories(self) -> list[str]: def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" # If we support two modes, one being off, we allow turning on too. - supported_features = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported_features = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if ( ( self.entity.domain == CLIMATE_DOMAIN and climate.HVACMode.OFF - in (self.entity.attributes.get(climate.ATTR_HVAC_MODES) or []) + in ( + self.entity.attributes.get( + ClimateEntityCapabilityAttribute.HVAC_MODES + ) + or [] + ) ) or ( self.entity.domain == CLIMATE_DOMAIN @@ -533,7 +560,9 @@ def interfaces(self) -> Generator[AlexaCapability]: supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE ) - and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) + and self.entity.attributes.get( + WaterHeaterCapabilityAttribute.OPERATION_LIST + ) ): yield AlexaModeController( self.entity, @@ -550,7 +579,7 @@ class CoverCapabilities(AlexaEntity): @override def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" - device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class in (cover.CoverDeviceClass.GARAGE, cover.CoverDeviceClass.GATE): return [DisplayCategory.GARAGE_DOOR] if device_class == cover.CoverDeviceClass.DOOR: @@ -573,14 +602,16 @@ def default_display_categories(self) -> list[str]: @override def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" - device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class not in ( cover.CoverDeviceClass.GARAGE, cover.CoverDeviceClass.GATE, ): yield AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & cover.CoverEntityFeature.SET_POSITION: yield AlexaRangeController( self.entity, instance=f"{COVER_DOMAIN}.{cover.ATTR_POSITION}" @@ -609,7 +640,9 @@ class EventCapabilities(AlexaEntity): def default_display_categories(self) -> list[str] | None: """Return the display categories for this entity.""" attrs = self.entity.attributes - device_class: event.EventDeviceClass | None = attrs.get(ATTR_DEVICE_CLASS) + device_class: event.EventDeviceClass | None = attrs.get( + EntityStateAttribute.DEVICE_CLASS + ) if device_class == event.EventDeviceClass.DOORBELL: return [DisplayCategory.DOORBELL] return None @@ -637,7 +670,9 @@ def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - color_modes = self.entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) + color_modes = self.entity.attributes.get( + LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + ) if light.brightness_supported(color_modes): yield AlexaBrightnessController(self.entity) if light.color_supported(color_modes): @@ -663,14 +698,16 @@ def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) force_range_controller = True - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & fan.FanEntityFeature.OSCILLATE: yield AlexaToggleController( self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_OSCILLATING}" ) force_range_controller = False if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( - fan.ATTR_PRESET_MODES + FanEntityCapabilityAttribute.PRESET_MODES ): yield AlexaModeController( self.entity, instance=f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}" @@ -709,12 +746,16 @@ def default_display_categories(self) -> list[str]: def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) + activities = ( + self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST) or [] + ) if ( activities and (supported & remote.RemoteEntityFeature.ACTIVITY) - and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + and self.entity.attributes.get(RemoteEntityStateAttribute.ACTIVITY_LIST) ): yield AlexaModeController( self.entity, instance=f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}" @@ -736,10 +777,14 @@ def default_display_categories(self) -> list[str]: def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if ( supported & humidifier.HumidifierEntityFeature.MODES - ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): + ) and self.entity.attributes.get( + HumidifierEntityCapabilityAttribute.AVAILABLE_MODES + ): yield AlexaModeController( self.entity, instance=f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}" ) @@ -775,7 +820,7 @@ class MediaPlayerCapabilities(AlexaEntity): @override def default_display_categories(self) -> list[str]: """Return the display categories for this entity.""" - device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + device_class = self.entity.attributes.get(EntityStateAttribute.DEVICE_CLASS) if device_class == media_player.MediaPlayerDeviceClass.SPEAKER: return [DisplayCategory.SPEAKER] @@ -786,7 +831,9 @@ def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & media_player.MediaPlayerEntityFeature.VOLUME_SET: yield AlexaSpeaker(self.entity) elif supported & media_player.MediaPlayerEntityFeature.VOLUME_STEP: @@ -808,7 +855,9 @@ def interfaces(self) -> Generator[AlexaCapability]: if supported & media_player.MediaPlayerEntityFeature.SELECT_SOURCE: inputs = AlexaInputController.get_valid_inputs( - self.entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST, []) + self.entity.attributes.get( + MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST, [] + ) ) if len(inputs) > 0: yield AlexaInputController(self.entity) @@ -825,7 +874,10 @@ def interfaces(self) -> Generator[AlexaCapability]: and domain != "denonavr" ): inputs = AlexaEqualizerController.get_valid_inputs( - self.entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) or [] + self.entity.attributes.get( + MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST + ) + or [] ) if len(inputs) > 0: yield AlexaEqualizerController(self.entity) @@ -889,7 +941,7 @@ def default_display_categories(self) -> list[str]: def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" attrs = self.entity.attributes - if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in { + if attrs.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) in { UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, }: @@ -947,7 +999,7 @@ def interfaces(self) -> Generator[AlexaCapability]: def get_type(self) -> str | None: """Return the type of binary sensor.""" attrs = self.entity.attributes - if attrs.get(ATTR_DEVICE_CLASS) in ( + if attrs.get(EntityStateAttribute.DEVICE_CLASS) in ( binary_sensor.BinarySensorDeviceClass.DOOR, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, binary_sensor.BinarySensorDeviceClass.OPENING, @@ -955,11 +1007,14 @@ def get_type(self) -> str | None: ): return self.TYPE_CONTACT - if attrs.get(ATTR_DEVICE_CLASS) == binary_sensor.BinarySensorDeviceClass.MOTION: + if ( + attrs.get(EntityStateAttribute.DEVICE_CLASS) + == binary_sensor.BinarySensorDeviceClass.MOTION + ): return self.TYPE_MOTION if ( - attrs.get(ATTR_DEVICE_CLASS) + attrs.get(EntityStateAttribute.DEVICE_CLASS) == binary_sensor.BinarySensorDeviceClass.PRESENCE ): return self.TYPE_PRESENCE @@ -979,7 +1034,9 @@ def default_display_categories(self) -> list[str]: @override def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" - if not self.entity.attributes.get("code_arm_required"): + if not self.entity.attributes.get( + AlarmControlPanelEntityStateAttribute.CODE_ARM_REQUIRED + ): yield AlexaSecurityPanelController(self.hass, self.entity) yield AlexaEndpointHealth(self.hass, self.entity) yield Alexa(self.entity) @@ -1050,7 +1107,9 @@ def default_display_categories(self) -> list[str]: @override def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if ( (supported & vacuum.VacuumEntityFeature.TURN_ON) or (supported & vacuum.VacuumEntityFeature.START) @@ -1087,7 +1146,9 @@ def default_display_categories(self) -> list[str]: @override def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & valve.ValveEntityFeature.SET_POSITION: yield AlexaRangeController( self.entity, instance=f"{VALVE_DOMAIN}.{valve.ATTR_POSITION}" @@ -1115,7 +1176,9 @@ def default_display_categories(self) -> list[str]: def interfaces(self) -> Generator[AlexaCapability]: """Yield the supported interfaces.""" if self._check_requirements(): - supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = self.entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) if supported & camera.CameraEntityFeature.STREAM: yield AlexaCameraStreamController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 5909e54e7baaf1..543d4f6ff43824 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -27,24 +27,58 @@ water_heater, ) from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN -from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN -from homeassistant.components.fan import DOMAIN as FAN_DOMAIN +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + ClimateEntityCapabilityAttribute, + ClimateEntityStateAttribute, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + CoverEntityStateAttribute, +) +from homeassistant.components.fan import ( + DOMAIN as FAN_DOMAIN, + FanEntityCapabilityAttribute, + FanEntityStateAttribute, +) from homeassistant.components.group import DOMAIN as GROUP_DOMAIN -from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN +from homeassistant.components.humidifier import ( + DOMAIN as HUMIDIFIER_DOMAIN, + HumidifierEntityCapabilityAttribute, + HumidifierEntityStateAttribute, +) from homeassistant.components.input_button import DOMAIN as INPUT_BUTTON_DOMAIN from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN -from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN -from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN +from homeassistant.components.light import ( + LightEntityCapabilityAttribute, + LightEntityStateAttribute, +) +from homeassistant.components.media_player import ( + DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerEntityCapabilityAttribute, + MediaPlayerEntityStateAttribute, +) +from homeassistant.components.number import ( + DOMAIN as NUMBER_DOMAIN, + NumberEntityCapabilityAttribute, +) +from homeassistant.components.remote import ( + DOMAIN as REMOTE_DOMAIN, + RemoteEntityStateAttribute, +) from homeassistant.components.timer import DOMAIN as TIMER_DOMAIN -from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN +from homeassistant.components.vacuum import ( + DOMAIN as VACUUM_DOMAIN, + VacuumEntityCapabilityAttribute, + VacuumEntityStateAttribute, +) from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN -from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.components.water_heater import ( + DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterCapabilityAttribute, +) from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_ENTITY_PICTURE, - ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, @@ -65,6 +99,7 @@ SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, + EntityStateAttribute, UnitOfTemperature, ) from homeassistant.helpers import network @@ -205,7 +240,7 @@ async def async_api_turn_on( elif domain == REMOTE_DOMAIN: service = remote.SERVICE_TURN_ON elif domain == VACUUM_DOMAIN: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) if ( not supported & vacuum.VacuumEntityFeature.TURN_ON and supported & vacuum.VacuumEntityFeature.START @@ -214,7 +249,7 @@ async def async_api_turn_on( elif domain == TIMER_DOMAIN: service = timer.SERVICE_START elif domain == MEDIA_PLAYER_DOMAIN: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) power_features = ( media_player.MediaPlayerEntityFeature.TURN_ON | media_player.MediaPlayerEntityFeature.TURN_OFF @@ -258,7 +293,7 @@ async def async_api_turn_off( elif domain == HUMIDIFIER_DOMAIN: service = humidifier.SERVICE_TURN_OFF elif domain == VACUUM_DOMAIN: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) if ( not supported & vacuum.VacuumEntityFeature.TURN_OFF and supported & vacuum.VacuumEntityFeature.RETURN_HOME @@ -267,7 +302,7 @@ async def async_api_turn_off( elif domain == TIMER_DOMAIN: service = timer.SERVICE_CANCEL elif domain == MEDIA_PLAYER_DOMAIN: - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) power_features = ( media_player.MediaPlayerEntityFeature.TURN_ON | media_player.MediaPlayerEntityFeature.TURN_OFF @@ -391,8 +426,10 @@ async def async_api_decrease_color_temp( ) -> AlexaResponse: """Process a decrease color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) - min_kelvin = int(entity.attributes[light.ATTR_MIN_COLOR_TEMP_KELVIN]) + current = int(entity.attributes[LightEntityStateAttribute.COLOR_TEMP_KELVIN]) + min_kelvin = int( + entity.attributes[LightEntityCapabilityAttribute.MIN_COLOR_TEMP_KELVIN] + ) value = max(min_kelvin, current - 500) await hass.services.async_call( @@ -415,8 +452,10 @@ async def async_api_increase_color_temp( ) -> AlexaResponse: """Process an increase color temperature request.""" entity = directive.entity - current = int(entity.attributes[light.ATTR_COLOR_TEMP_KELVIN]) - max_kelvin = int(entity.attributes[light.ATTR_MAX_COLOR_TEMP_KELVIN]) + current = int(entity.attributes[LightEntityStateAttribute.COLOR_TEMP_KELVIN]) + max_kelvin = int( + entity.attributes[LightEntityCapabilityAttribute.MAX_COLOR_TEMP_KELVIN] + ) value = min(max_kelvin, current + 500) await hass.services.async_call( @@ -604,7 +643,10 @@ async def async_api_select_input( # Attempt to map the ALL UPPERCASE payload name to a source. # Strips trailing 1 to match single input devices. - source_list = entity.attributes.get(media_player.ATTR_INPUT_SOURCE_LIST) or [] + source_list = ( + entity.attributes.get(MediaPlayerEntityCapabilityAttribute.INPUT_SOURCE_LIST) + or [] + ) for source in source_list: formatted_source = ( source.lower().replace("-", "").replace("_", "").replace(" ", "") @@ -651,7 +693,9 @@ async def async_api_adjust_volume( volume_delta = int(directive.payload["volume"]) entity = directive.entity - current_level = entity.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] + current_level = entity.attributes[ + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL + ] # read current state try: @@ -782,7 +826,9 @@ async def async_api_stop( data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} if entity.domain == COVER_DOMAIN: - supported: int = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported: int = entity.attributes.get( + EntityStateAttribute.SUPPORTED_FEATURES, 0 + ) feature_services: dict[int, str] = { cover.CoverEntityFeature.STOP.value: cover.SERVICE_STOP_COVER, cover.CoverEntityFeature.STOP_TILT.value: cover.SERVICE_STOP_COVER_TILT, @@ -875,7 +921,7 @@ async def async_api_set_target_temp( domain = entity.domain min_temp = entity.attributes[MIN_MAX_TEMP[domain]["min_temp"]] - max_temp = entity.attributes["max_temp"] + max_temp = entity.attributes[ClimateEntityCapabilityAttribute.MAX_TEMP] unit = hass.config.units.temperature_unit data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} @@ -953,8 +999,12 @@ async def async_api_adjust_target_temp( response = directive.response() - current_target_temp_high = entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - current_target_temp_low = entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) + current_target_temp_high = entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMP_HIGH + ) + current_target_temp_low = entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMP_LOW + ) if current_target_temp_high is not None and current_target_temp_low is not None: target_temp_high = float(current_target_temp_high) + temp_delta if target_temp_high < min_temp or target_temp_high > max_temp: @@ -985,7 +1035,9 @@ async def async_api_adjust_target_temp( } ) else: - current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE) + current_target_temp: str | None = entity.attributes.get( + ClimateEntityStateAttribute.TARGET_TEMPERATURE + ) if current_target_temp is None: raise AlexaUnsupportedThermostatTargetStateError( "The current target temperature is not set, " @@ -1037,7 +1089,9 @@ async def async_api_set_thermostat_mode( ha_preset = next((k for k, v in API_THERMOSTAT_PRESETS.items() if v == mode), None) if ha_preset: - presets = entity.attributes.get(climate.ATTR_PRESET_MODES) or [] + presets = ( + entity.attributes.get(ClimateEntityCapabilityAttribute.PRESET_MODES) or [] + ) if ha_preset not in presets: msg = f"The requested thermostat mode {ha_preset} is not supported" @@ -1047,7 +1101,9 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_PRESET_MODE] = ha_preset elif mode == "CUSTOM": - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] + operation_list = ( + entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES) or [] + ) custom_mode = directive.payload["thermostatMode"]["customName"] custom_mode = next( (k for k, v in API_THERMOSTAT_MODES_CUSTOM.items() if v == custom_mode), @@ -1063,7 +1119,9 @@ async def async_api_set_thermostat_mode( data[climate.ATTR_HVAC_MODE] = custom_mode else: - operation_list = entity.attributes.get(climate.ATTR_HVAC_MODES) or [] + operation_list = ( + entity.attributes.get(ClimateEntityCapabilityAttribute.HVAC_MODES) or [] + ) ha_modes: dict[str, str] = { k: v for k, v in API_THERMOSTAT_MODES.items() if v == mode } @@ -1221,7 +1279,9 @@ async def async_api_set_mode( # Fan preset_mode elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PRESET_MODE}": preset_mode = mode.split(".")[1] - preset_modes: list[str] | None = entity.attributes.get(fan.ATTR_PRESET_MODES) + preset_modes: list[str] | None = entity.attributes.get( + FanEntityCapabilityAttribute.PRESET_MODES + ) if ( preset_mode != PRESET_MODE_NA and preset_modes @@ -1236,7 +1296,9 @@ async def async_api_set_mode( # Humidifier mode elif instance == f"{HUMIDIFIER_DOMAIN}.{humidifier.ATTR_MODE}": mode = mode.split(".")[1] - modes: list[str] | None = entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES) + modes: list[str] | None = entity.attributes.get( + HumidifierEntityCapabilityAttribute.AVAILABLE_MODES + ) if mode != PRESET_MODE_NA and modes and mode in modes: service = humidifier.SERVICE_SET_MODE data[humidifier.ATTR_MODE] = mode @@ -1247,7 +1309,9 @@ async def async_api_set_mode( # Remote Activity elif instance == f"{REMOTE_DOMAIN}.{remote.ATTR_ACTIVITY}": activity = mode.split(".")[1] - activities: list[str] | None = entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + activities: list[str] | None = entity.attributes.get( + RemoteEntityStateAttribute.ACTIVITY_LIST + ) if activity != PRESET_MODE_NA and activities and activity in activities: service = remote.SERVICE_TURN_ON data[remote.ATTR_ACTIVITY] = activity @@ -1259,7 +1323,7 @@ async def async_api_set_mode( elif instance == f"{WATER_HEATER_DOMAIN}.{water_heater.ATTR_OPERATION_MODE}": operation_mode = mode.split(".")[1] operation_modes: list[str] | None = entity.attributes.get( - water_heater.ATTR_OPERATION_LIST + WaterHeaterCapabilityAttribute.OPERATION_LIST ) if ( operation_mode != PRESET_MODE_NA @@ -1432,7 +1496,7 @@ async def async_api_set_range( service = None data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} range_value = directive.payload["rangeValue"] - supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + supported = entity.attributes.get(EntityStateAttribute.SUPPORTED_FEATURES, 0) # Cover Position if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}": @@ -1485,14 +1549,14 @@ async def async_api_set_range( elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}": range_value = float(range_value) service = number.SERVICE_SET_VALUE - min_value = float(entity.attributes[number.ATTR_MIN]) - max_value = float(entity.attributes[number.ATTR_MAX]) + min_value = float(entity.attributes[NumberEntityCapabilityAttribute.MIN]) + max_value = float(entity.attributes[NumberEntityCapabilityAttribute.MAX]) data[number.ATTR_VALUE] = min(max_value, max(min_value, range_value)) # Vacuum Fan Speed elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}": service = vacuum.SERVICE_SET_FAN_SPEED - speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] + speed_list = entity.attributes[VacuumEntityCapabilityAttribute.FAN_SPEED_LIST] speed = next( (v for i, v in enumerate(speed_list) if i == int(range_value)), None ) @@ -1555,7 +1619,9 @@ async def async_api_adjust_range( if instance == f"{COVER_DOMAIN}.{cover.ATTR_POSITION}": range_delta = int(range_delta * 20) if range_delta_default else int(range_delta) service = SERVICE_SET_COVER_POSITION - if not (current := entity.attributes.get(cover.ATTR_CURRENT_POSITION)): + if not ( + current := entity.attributes.get(CoverEntityStateAttribute.CURRENT_POSITION) + ): msg = f"Unable to determine {entity.entity_id} current position" raise AlexaInvalidValueError(msg) position = response_value = min(100, max(0, range_delta + current)) @@ -1584,14 +1650,16 @@ async def async_api_adjust_range( # Fan speed percentage elif instance == f"{FAN_DOMAIN}.{fan.ATTR_PERCENTAGE}": - percentage_step = entity.attributes.get(fan.ATTR_PERCENTAGE_STEP) or 20 + percentage_step = ( + entity.attributes.get(FanEntityStateAttribute.PERCENTAGE_STEP) or 20 + ) range_delta = ( int(range_delta * percentage_step) if range_delta_default else int(range_delta) ) service = fan.SERVICE_SET_PERCENTAGE - if not (current := entity.attributes.get(fan.ATTR_PERCENTAGE)): + if not (current := entity.attributes.get(FanEntityStateAttribute.PERCENTAGE)): msg = f"Unable to determine {entity.entity_id} current fan speed" raise AlexaInvalidValueError(msg) percentage = response_value = min(100, max(0, range_delta + current)) @@ -1609,11 +1677,17 @@ async def async_api_adjust_range( else int(range_delta) ) service = humidifier.SERVICE_SET_HUMIDITY - if not (current := entity.attributes.get(humidifier.ATTR_HUMIDITY)): + if not ( + current := entity.attributes.get(HumidifierEntityStateAttribute.HUMIDITY) + ): msg = f"Unable to determine {entity.entity_id} current target humidity" raise AlexaInvalidValueError(msg) - min_value = entity.attributes.get(humidifier.ATTR_MIN_HUMIDITY, 10) - max_value = entity.attributes.get(humidifier.ATTR_MAX_HUMIDITY, 90) + min_value = entity.attributes.get( + HumidifierEntityCapabilityAttribute.MIN_HUMIDITY, 10 + ) + max_value = entity.attributes.get( + HumidifierEntityCapabilityAttribute.MAX_HUMIDITY, 90 + ) percentage = response_value = min( max_value, max(min_value, range_delta + current) ) @@ -1635,8 +1709,8 @@ async def async_api_adjust_range( elif instance == f"{NUMBER_DOMAIN}.{number.ATTR_VALUE}": range_delta = float(range_delta) service = number.SERVICE_SET_VALUE - min_value = float(entity.attributes[number.ATTR_MIN]) - max_value = float(entity.attributes[number.ATTR_MAX]) + min_value = float(entity.attributes[NumberEntityCapabilityAttribute.MIN]) + max_value = float(entity.attributes[NumberEntityCapabilityAttribute.MAX]) current = float(entity.state) data[number.ATTR_VALUE] = response_value = min( max_value, max(min_value, range_delta + current) @@ -1646,8 +1720,8 @@ async def async_api_adjust_range( elif instance == f"{VACUUM_DOMAIN}.{vacuum.ATTR_FAN_SPEED}": range_delta = int(range_delta) service = vacuum.SERVICE_SET_FAN_SPEED - speed_list = entity.attributes[vacuum.ATTR_FAN_SPEED_LIST] - current_speed = entity.attributes[vacuum.ATTR_FAN_SPEED] + speed_list = entity.attributes[VacuumEntityCapabilityAttribute.FAN_SPEED_LIST] + current_speed = entity.attributes[VacuumEntityStateAttribute.FAN_SPEED] current_speed_index = next( (i for i, v in enumerate(speed_list) if v == current_speed), 0 ) @@ -1798,14 +1872,18 @@ async def async_api_seek( entity = directive.entity position_delta = int(directive.payload["deltaPositionMilliseconds"]) - current_position = entity.attributes.get(media_player.ATTR_MEDIA_POSITION) + current_position = entity.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_POSITION + ) if not current_position: msg = f"{entity} did not return the current media position." raise AlexaVideoActionNotPermittedForContentError(msg) seek_position = max(int(current_position) + int(position_delta / 1000), 0) - media_duration = entity.attributes.get(media_player.ATTR_MEDIA_DURATION) + media_duration = entity.attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_DURATION + ) if media_duration and 0 < int(media_duration) < seek_position: seek_position = media_duration @@ -1845,7 +1923,9 @@ async def async_api_set_eq_mode( entity = directive.entity data: dict[str, Any] = {ATTR_ENTITY_ID: entity.entity_id} - sound_mode_list = entity.attributes.get(media_player.ATTR_SOUND_MODE_LIST) + sound_mode_list = entity.attributes.get( + MediaPlayerEntityCapabilityAttribute.SOUND_MODE_LIST + ) if sound_mode_list and mode.lower() in sound_mode_list: data[media_player.ATTR_SOUND_MODE] = mode.lower() else: @@ -1946,7 +2026,7 @@ async def async_api_initialize_camera_stream( stream_source = await camera.async_request_stream(hass, entity.entity_id, fmt="hls") state = hass.states.get(entity.entity_id) assert state - camera_image = state.attributes[ATTR_ENTITY_PICTURE] + camera_image = state.attributes[EntityStateAttribute.ENTITY_PICTURE] try: external_url = network.get_url( diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 6388fd4b46ce85..59c38425cb5db7 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -6,7 +6,12 @@ from aiohttp.client_exceptions import ClientResponseError from bond_async import Action, DeviceType -from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ColorMode, + LightEntity, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity @@ -120,7 +125,16 @@ def __init__( ) -> None: """Create HA entity representing Bond light.""" super().__init__(data, device, sub_device) - if device.supports_set_brightness(): + if device.supports_set_color_temp(): + self._attr_color_mode = ColorMode.COLOR_TEMP + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + self._attr_min_color_temp_kelvin = ( + device.props.get("min_color_temp") or self._attr_min_color_temp_kelvin + ) + self._attr_max_color_temp_kelvin = ( + device.props.get("max_color_temp") or self._attr_max_color_temp_kelvin + ) + elif device.supports_set_brightness(): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -130,16 +144,33 @@ def _apply_state(self) -> None: self._attr_is_on = state.get("light") == 1 brightness = state.get("brightness") self._attr_brightness = round(brightness * 255 / 100) if brightness else None + color_temp_kelvin = state.get("color_temp") + # API resolution is 100K + self._attr_color_temp_kelvin = ( + round(color_temp_kelvin, -2) if color_temp_kelvin else None + ) @override async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" + basic_on = True + if brightness := kwargs.get(ATTR_BRIGHTNESS): await self._bond.action( self._device_id, Action.set_brightness(round((brightness * 100) / 255)), ) - else: + basic_on = False + + if color_temp := kwargs.get(ATTR_COLOR_TEMP_KELVIN): + await self._bond.action( + self._device_id, + # API resolution is 100K + Action.set_color_temperature(round(color_temp, -2)), + ) + basic_on = False + + if basic_on: await self._bond.action(self._device_id, Action.turn_light_on()) @override diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 4521c0c1394263..df81ffbe7f6721 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -130,6 +130,10 @@ def supports_set_brightness(self) -> bool: """Return True if this device supports setting a light brightness.""" return self._has_any_action({Action.SET_BRIGHTNESS}) + def supports_set_color_temp(self) -> bool: + """Return True if this device supports setting a light color temperature.""" + return self._has_any_action({Action.SET_COLOR_TEMP}) + class BondHub: """Hub device representing Bond Bridge.""" diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 2f0130a4bca385..70da46d493c49b 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -82,7 +82,7 @@ _DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that" -_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"] +_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "device_id", "name", "original_name"] _DEVICE_REGISTRY_UPDATE_FIELDS = ["name", "name_by_user"] _DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index ba0de3a35ac09e..f1223f7dc068b7 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -26,15 +26,24 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, ClimateEntityFeature, + ClimateEntityStateAttribute, ) from homeassistant.components.cover import ( - ATTR_CURRENT_POSITION, ATTR_POSITION, CoverEntityFeature, + CoverEntityStateAttribute, +) +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + FanEntityFeature, + FanEntityStateAttribute, ) -from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntityFeature from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.components.humidifier import ATTR_HUMIDITY, SERVICE_SET_HUMIDITY +from homeassistant.components.humidifier import ( + ATTR_HUMIDITY, + SERVICE_SET_HUMIDITY, + HumidifierEntityStateAttribute, +) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -42,11 +51,14 @@ ATTR_TRANSITION, ATTR_XY_COLOR, ColorMode, + LightEntityCapabilityAttribute, LightEntityFeature, + LightEntityStateAttribute, ) from homeassistant.components.media_player import ( ATTR_MEDIA_VOLUME_LEVEL, MediaPlayerEntityFeature, + MediaPlayerEntityStateAttribute, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -388,7 +400,7 @@ async def put( # noqa: C901 if entity.domain == light.DOMAIN: color_modes = ( entity.attributes.get( - light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES + LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES ) or [] ) @@ -704,9 +716,9 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: attributes = entity.attributes if is_on: data[STATE_BRIGHTNESS] = hass_to_hue_brightness( - attributes.get(ATTR_BRIGHTNESS) or 0 + attributes.get(LightEntityStateAttribute.BRIGHTNESS) or 0 ) - if (hue_sat := attributes.get(ATTR_HS_COLOR)) is not None: + if (hue_sat := attributes.get(LightEntityStateAttribute.HS_COLOR)) is not None: hue = hue_sat[0] sat = hue_sat[1] # Convert hass hs values back to hue hs values @@ -715,7 +727,7 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: else: data[STATE_HUE] = HUE_API_STATE_HUE_MIN data[STATE_SATURATION] = HUE_API_STATE_SAT_MIN - kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN) + kelvin = attributes.get(LightEntityStateAttribute.COLOR_TEMP_KELVIN) data[STATE_COLOR_TEMP] = ( color_util.color_temperature_kelvin_to_mired(kelvin) if kelvin is not None @@ -729,23 +741,26 @@ def _build_entity_state_dict(entity: State) -> dict[str, Any]: data[STATE_COLOR_TEMP] = 0 if entity.domain == climate.DOMAIN: - temperature = attributes.get(ATTR_TEMPERATURE, 0) + temperature = attributes.get(ClimateEntityStateAttribute.TARGET_TEMPERATURE, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(temperature * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == humidifier.DOMAIN: - humidity = attributes.get(ATTR_HUMIDITY, 0) + humidity = attributes.get(HumidifierEntityStateAttribute.HUMIDITY, 0) # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(humidity * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == media_player.DOMAIN: - level = attributes.get(ATTR_MEDIA_VOLUME_LEVEL, 1.0 if is_on else 0.0) + level = attributes.get( + MediaPlayerEntityStateAttribute.MEDIA_VOLUME_LEVEL, + 1.0 if is_on else 0.0, + ) # Convert 0.0-1.0 to 0-254 data[STATE_BRIGHTNESS] = round(min(1.0, level) * HUE_API_STATE_BRI_MAX) elif entity.domain == fan.DOMAIN: - percentage = attributes.get(ATTR_PERCENTAGE) or 0 + percentage = attributes.get(FanEntityStateAttribute.PERCENTAGE) or 0 # Convert 0-100 to 0-254 data[STATE_BRIGHTNESS] = round(percentage * HUE_API_STATE_BRI_MAX / 100) elif entity.domain == cover.DOMAIN: - level = attributes.get(ATTR_CURRENT_POSITION, 0) + level = attributes.get(CoverEntityStateAttribute.CURRENT_POSITION, 0) data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX) _clamp_values(data) return data @@ -777,8 +792,7 @@ def _entity_unique_id(entity_id: str) -> str: def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" color_modes = ( - state.attributes.get(light.LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) - or [] + state.attributes.get(LightEntityCapabilityAttribute.SUPPORTED_COLOR_MODES) or [] ) unique_id = _entity_unique_id(state.entity_id) state_dict = get_entity_state_dict(config, state) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index fe2cc9f652e04a..3b4c90b672b3c3 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.102", "babel==2.18.0"] + "requirements": ["holidays==0.103", "babel==2.18.0"] } diff --git a/homeassistant/components/lunatone/__init__.py b/homeassistant/components/lunatone/__init__.py index 5a2f28674755dd..dde1f64bb4e382 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -3,7 +3,14 @@ import logging from typing import Final -from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info, Sensors +from lunatone_rest_api_client import ( + Auth, + DALIBroadcast, + DALIScan, + Devices, + Info, + Sensors, +) from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant @@ -18,11 +25,16 @@ LunatoneData, LunatoneDevicesDataUpdateCoordinator, LunatoneInfoDataUpdateCoordinator, + LunatoneScanDataUpdateCoordinator, LunatoneSensorsDataUpdateCoordinator, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS: Final[list[Platform]] = [Platform.LIGHT, Platform.SENSOR] +PLATFORMS: Final[list[Platform]] = [ + Platform.BINARY_SENSOR, + Platform.LIGHT, + Platform.SENSOR, +] async def _update_unique_id( @@ -70,6 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) info_api = Info(auth_api) + dali_scan_api = DALIScan(auth_api) devices_api = Devices(info_api) sensors_api = Sensors(auth_api) @@ -110,6 +123,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> coordinator_sensors = LunatoneSensorsDataUpdateCoordinator(hass, entry, sensors_api) await coordinator_sensors.async_config_entry_first_refresh() + coordinator_scan = LunatoneScanDataUpdateCoordinator(hass, entry, dali_scan_api) + await coordinator_scan.async_config_entry_first_refresh() + dali_line_broadcasts = [ DALIBroadcast(auth_api, int(line)) for line in coordinator_info.data.lines ] @@ -118,6 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> coordinator_info, coordinator_devices, coordinator_sensors, + coordinator_scan, dali_line_broadcasts, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/lunatone/binary_sensor.py b/homeassistant/components/lunatone/binary_sensor.py new file mode 100644 index 00000000000000..c05ef6cc616274 --- /dev/null +++ b/homeassistant/components/lunatone/binary_sensor.py @@ -0,0 +1,65 @@ +"""Platform for Lunatone binary sensor integration.""" + +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LunatoneConfigEntry, LunatoneScanDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Lunatone binary sensors from the config entry.""" + coordinator_scan = config_entry.runtime_data.coordinator_scan + + assert config_entry.unique_id is not None + + async_add_entities( + [LunatoneDALIScanStatus(coordinator_scan, config_entry.unique_id)] + ) + + +class LunatoneDALIScanStatus( + CoordinatorEntity[LunatoneScanDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Lunatone DALI scan status.""" + + _attr_device_class = BinarySensorDeviceClass.RUNNING + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_has_entity_name = True + _attr_translation_key = "scan_status" + + def __init__( + self, + coordinator: LunatoneScanDataUpdateCoordinator, + config_entry_unique_id: str, + ) -> None: + """Initialize a Lunatone DALI scan status.""" + super().__init__(coordinator) + + self._config_entry_unique_id = config_entry_unique_id + + self._attr_unique_id = f"{config_entry_unique_id}-scan-progress" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._config_entry_unique_id)}, + ) + + @property + @override + def is_on(self) -> bool: + """Return true if the DALI scan is on.""" + return self.coordinator.dali_scan_api.is_busy diff --git a/homeassistant/components/lunatone/coordinator.py b/homeassistant/components/lunatone/coordinator.py index f5a591e5f2966b..6170309d09110e 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -8,13 +8,14 @@ import aiohttp from lunatone_rest_api_client import ( DALIBroadcast, + DALIScan, Device, Devices, Info, Sensor, Sensors, ) -from lunatone_rest_api_client.models import InfoData +from lunatone_rest_api_client.models import InfoData, ScanData from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -24,9 +25,10 @@ _LOGGER = logging.getLogger(__name__) -DEFAULT_INFO_SCAN_INTERVAL = timedelta(seconds=60) -DEFAULT_DEVICES_SCAN_INTERVAL = timedelta(seconds=10) -DEFAULT_SENSORS_SCAN_INTERVAL = timedelta(seconds=30) +DEFAULT_INFO_UPDATE_INTERVAL = timedelta(seconds=60) +DEFAULT_DEVICES_UPDATE_INTERVAL = timedelta(seconds=10) +DEFAULT_SENSORS_UPDATE_INTERVAL = timedelta(seconds=30) +DEFAULT_SCAN_UPDATE_INTERVAL = timedelta(seconds=10) @dataclass @@ -36,6 +38,7 @@ class LunatoneData: coordinator_info: LunatoneInfoDataUpdateCoordinator coordinator_devices: LunatoneDevicesDataUpdateCoordinator coordinator_sensors: LunatoneSensorsDataUpdateCoordinator + coordinator_scan: LunatoneScanDataUpdateCoordinator dali_line_broadcasts: list[DALIBroadcast] @@ -57,7 +60,7 @@ def __init__( config_entry=config_entry, name=f"{DOMAIN}-info", always_update=False, - update_interval=DEFAULT_INFO_SCAN_INTERVAL, + update_interval=DEFAULT_INFO_UPDATE_INTERVAL, ) self.info_api = info_api @@ -94,7 +97,7 @@ def __init__( config_entry=config_entry, name=f"{DOMAIN}-devices", always_update=False, - update_interval=DEFAULT_DEVICES_SCAN_INTERVAL, + update_interval=DEFAULT_DEVICES_UPDATE_INTERVAL, ) self.devices_api = devices_api @@ -131,7 +134,7 @@ def __init__( config_entry=config_entry, name=f"{DOMAIN}-sensors", always_update=False, - update_interval=DEFAULT_SENSORS_SCAN_INTERVAL, + update_interval=DEFAULT_SENSORS_UPDATE_INTERVAL, ) self.sensors_api = sensors_api @@ -149,3 +152,46 @@ async def _async_update_data(self) -> dict[int, Sensor]: if self.sensors_api.data is None: raise UpdateFailed("Did not receive sensors data from Lunatone REST API") return {sensor.id: sensor for sensor in self.sensors_api.sensors} + + +class LunatoneScanDataUpdateCoordinator(DataUpdateCoordinator[ScanData]): + """Data update coordinator for Lunatone scan.""" + + config_entry: LunatoneConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: LunatoneConfigEntry, + dali_scan_api: DALIScan, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}-scan", + always_update=False, + update_interval=DEFAULT_SCAN_UPDATE_INTERVAL, + ) + self.dali_scan_api = dali_scan_api + + @override + async def _async_update_data(self) -> ScanData: + """Update scan data.""" + try: + await self.dali_scan_api.async_update() + except aiohttp.ClientConnectionError as ex: + raise UpdateFailed( + "Unable to retrieve scan data from Lunatone REST API" + ) from ex + + if self.dali_scan_api.data is None: + raise UpdateFailed("Did not receive scan data from Lunatone REST API") + + update_interval = DEFAULT_SCAN_UPDATE_INTERVAL + if self.dali_scan_api.is_busy: + update_interval = timedelta(seconds=1) + self.update_interval = update_interval + + return self.dali_scan_api.data diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index ff57187e1e16c1..2d2b6b20800a0e 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -37,6 +37,13 @@ } } }, + "entity": { + "binary_sensor": { + "scan_status": { + "name": "DALI scan" + } + } + }, "exceptions": { "missing_device_info": { "message": "Unable to read device information. Please verify the device's network connection." diff --git a/homeassistant/components/lyngdorf/manifest.json b/homeassistant/components/lyngdorf/manifest.json index ba3aa2baf8e344..6d69099b5e8adf 100644 --- a/homeassistant/components/lyngdorf/manifest.json +++ b/homeassistant/components/lyngdorf/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["lyngdorf", "async_upnp_client"], "quality_scale": "silver", - "requirements": ["lyngdorf==1.6.0"], + "requirements": ["lyngdorf==1.8.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 0e33470f6978e7..6a2bd02c52372b 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -4,11 +4,9 @@ from typing import TYPE_CHECKING from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_RESTORED, - ATTR_SUPPORTED_FEATURES, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401 EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 + EntityStateAttribute, ) from homeassistant.helpers.json import JSON_DUMP # noqa: F401 @@ -40,7 +38,11 @@ DB_WORKER_PREFIX = "DbWorker" -ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} +ALL_DOMAIN_EXCLUDE_ATTRS: set[str] = { + EntityStateAttribute.ATTRIBUTION, + EntityStateAttribute.RESTORED, + EntityStateAttribute.SUPPORTED_FEATURES, +} ATTR_KEEP_DAYS = "keep_days" ATTR_REPACK = "repack" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index e96d9b78011623..538bcc4f42d829 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -33,15 +33,13 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, aliased, mapped_column, relationship from sqlalchemy.types import TypeDecorator -from homeassistant.components.sensor import ATTR_STATE_CLASS +from homeassistant.components.sensor import SensorEntityCapabilityAttribute from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_UNIT_OF_MEASUREMENT, MATCH_ALL, MAX_LENGTH_EVENT_EVENT_TYPE, MAX_LENGTH_STATE_ENTITY_ID, MAX_LENGTH_STATE_STATE, + EntityStateAttribute, ) from homeassistant.core import Event, EventStateChangedData from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null @@ -139,11 +137,11 @@ class LegacyBase(DeclarativeBase): "mariadb_engine": MYSQL_ENGINE, } -_MATCH_ALL_KEEP = { - ATTR_DEVICE_CLASS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - ATTR_FRIENDLY_NAME, +_MATCH_ALL_KEEP: set[str] = { + EntityStateAttribute.DEVICE_CLASS, + EntityStateAttribute.UNIT_OF_MEASUREMENT, + EntityStateAttribute.FRIENDLY_NAME, + SensorEntityCapabilityAttribute.STATE_CLASS, } diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8302fb3ca82672..7a7225cfafe94f 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -30,7 +30,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement import voluptuous as vol -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import EntityStateAttribute from homeassistant.core import HomeAssistant, callback, valid_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.frame import report_usage @@ -368,7 +368,7 @@ def get_display_unit( state_unit: str | None = statistic_unit if state := hass.states.get(statistic_id): - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) if state_unit == statistic_unit or state_unit not in converter.VALID_UNITS: # Guard against invalid state unit in the DB @@ -1973,7 +1973,7 @@ def statistic_during_period( unit_class = metadata[1]["unit_class"] state_unit = unit = metadata[1]["unit_of_measurement"] if state := hass.states.get(statistic_id): - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get(EntityStateAttribute.UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter( unit_class, unit, state_unit, units ) @@ -2062,7 +2062,9 @@ def _augment_result_with_change( unit_class = metadata_by_id["unit_class"] state_unit = unit = metadata_by_id["unit_of_measurement"] if state := hass.states.get(statistic_id): - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) convert = _get_statistic_to_display_unit_converter( unit_class, unit, state_unit, units ) @@ -2688,7 +2690,9 @@ def _sorted_statistics_to_dict( unit_class = metadata_by_id["unit_class"] state_unit = unit = metadata_by_id["unit_of_measurement"] if state := hass.states.get(statistic_id): - state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + state_unit = state.attributes.get( + EntityStateAttribute.UNIT_OF_MEASUREMENT + ) convert = _get_statistic_to_display_unit_converter( unit_class, unit, state_unit, units, allow_none=False ) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 5e8abb115fca3e..a2b96fe7ea1715 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "platinum", - "requirements": ["aioshelly==13.29.0"], + "requirements": ["aioshelly==13.30.0"], "zeroconf": [ { "name": "shelly*", diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 20e5a51b6ac1df..a7d7404afaad90 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -288,6 +288,9 @@ def build_item_response( thumbnail = None title = None + # Library listings such as Albums and Artists are browsed, not played; only a + # single album resolved below can be played as a whole. + playable = False # Fetch album info for titles and thumbnails # Can't be extracted from track info @@ -303,7 +306,12 @@ def build_item_response( item = get_media(media_library, idstring, search_type) title = getattr(item, "title", None) - thumbnail = get_thumbnail_url(search_type, payload["idstring"]) + # The browse image proxy round-trips this back to async_get_browse_image, + # which matches on MediaType, not on the Sonos search type. + thumbnail = get_thumbnail_url( + SONOS_TO_MEDIA_TYPES[search_type], payload["idstring"] + ) + playable = can_play(search_type) if not title: title = _get_title(id_string=payload["idstring"]) @@ -328,7 +336,7 @@ def build_item_response( media_content_id=payload["idstring"], media_content_type=payload["search_type"], children=children, - can_play=can_play(payload["search_type"]), + can_play=playable, can_expand=can_expand(payload["search_type"]), ) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index 664d926435b7e1..865c7bf1b99411 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -68,8 +68,11 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> helper_config_entry_id=config_entry.entry_id, source_device_id=source_device_id, ) + if config_entry.minor_version < 3: + if options.get("sampling_size") == 0: + options.pop("sampling_size") hass.config_entries.async_update_entry( - config_entry, options=options, minor_version=2 + config_entry, options=options, minor_version=3 ) _LOGGER.debug( diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index 01a0255606ae0a..8d244e1846889f 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -118,7 +118,7 @@ async def validate_options( ) ), vol.Optional(CONF_SAMPLES_MAX_BUFFER_SIZE): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + NumberSelectorConfig(min=1, step=1, mode=NumberSelectorMode.BOX) ), vol.Optional(CONF_MAX_AGE): DurationSelector( DurationSelectorConfig(enable_day=False, allow_negative=False) @@ -159,7 +159,7 @@ async def validate_options( class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Statistics.""" - MINOR_VERSION = 2 + MINOR_VERSION = 3 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 4e3a7245b689d4..d76b8b3df78c8a 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -622,7 +622,7 @@ async def async_setup_entry( ) -> None: """Set up the Statistics sensor entry.""" sampling_size = entry.options.get(CONF_SAMPLES_MAX_BUFFER_SIZE) - if sampling_size: + if sampling_size is not None: sampling_size = int(sampling_size) max_age = None diff --git a/homeassistant/components/switchbot_cloud/light.py b/homeassistant/components/switchbot_cloud/light.py index b1dd9cf026dab5..c60afb8e7398de 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -99,17 +99,23 @@ async def async_turn_on(self, **kwargs: Any) -> None: rgb_color: tuple[int, int, int] | None = kwargs.get("rgb_color") color_temp_kelvin: int | None = kwargs.get("color_temp_kelvin") - if brightness is not None: - self._attr_color_mode = self._get_default_color_mode() - await self._send_brightness_command(brightness) - elif rgb_color is not None: + if rgb_color is not None: self._attr_color_mode = ColorMode.RGB - await self._send_rgb_color_command(rgb_color) elif color_temp_kelvin is not None: self._attr_color_mode = ColorMode.COLOR_TEMP - await self._send_color_temperature_command(color_temp_kelvin) else: self._attr_color_mode = self._get_default_color_mode() + + # Brightness adjustment can be sent by HASS in a single command with color adjustment, + # so we need to send brightness command separately if it is present. + if brightness is not None: + await self._send_brightness_command(brightness) + + if rgb_color is not None: + await self._send_rgb_color_command(rgb_color) + elif color_temp_kelvin is not None: + await self._send_color_temperature_command(color_temp_kelvin) + elif brightness is None: await self.send_api_command(CommonCommands.ON) await asyncio.sleep(AFTER_COMMAND_REFRESH) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 945947c69b24b4..8fb6451532cf37 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -48,6 +48,10 @@ class TraccarServerCoordinatorDataDevice(TypedDict): type TraccarServerCoordinatorData = dict[int, TraccarServerCoordinatorDataDevice] +_SUBSCRIPTION_FAILURE_LOG_EVERY_N_ATTEMPTS = 30 +_SUBSCRIPTION_RECONNECT_DELAY = 10 + + class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]): """Class to manage fetching Traccar Server data.""" @@ -77,6 +81,7 @@ def __init__( self._geofences: list[GeofenceModel] = [] self._last_event_import: datetime | None = None self._should_log_subscription_error: bool = True + self._consecutive_subscription_failures: int = 0 @override async def _async_update_data(self) -> TraccarServerCoordinatorData: @@ -146,6 +151,12 @@ async def _async_update_data(self) -> TraccarServerCoordinatorData: async def handle_subscription_data(self, data: SubscriptionData) -> None: """Handle subscription data.""" self.logger.debug("Received subscription data: %s", data) + if self._consecutive_subscription_failures: + self.logger.info( + "Traccar subscription connection restored after %s failed attempt(s)", + self._consecutive_subscription_failures, + ) + self._consecutive_subscription_failures = 0 self._should_log_subscription_error = True get_custom_attrs = ( self._return_custom_attributes_if_not_filtered_by_accuracy_configuration @@ -232,18 +243,53 @@ async def import_events(self, _: datetime) -> None: ) async def subscribe(self) -> None: - """Subscribe to events.""" - try: - await self.client.subscribe(self.handle_subscription_data) - except TraccarAuthenticationException: - raise ConfigEntryAuthFailed from None - except TraccarException as ex: - if self._should_log_subscription_error: - self._should_log_subscription_error = False - LOGGER.error("Error while subscribing to Traccar: %s", ex) - # Retry after 10 seconds - await asyncio.sleep(10) - await self.subscribe() + """Subscribe to events, reconnecting for the life of the config entry.""" + while True: + try: + await self.client.subscribe(self.handle_subscription_data) + except TraccarAuthenticationException: + raise ConfigEntryAuthFailed from None + except TraccarException as ex: + self._log_subscription_failure( + "Error while subscribing to Traccar", ex, log_traceback=False + ) + except Exception as ex: # noqa: BLE001 - keep retrying; a dead background task is worse than a logged surprise + self._log_subscription_failure( + "Unexpected error while subscribing to Traccar", + ex, + log_traceback=True, + ) + else: + self.logger.debug( + "Traccar subscription ended without error, reconnecting" + ) + self._consecutive_subscription_failures = 0 + self._should_log_subscription_error = True + + await asyncio.sleep(_SUBSCRIPTION_RECONNECT_DELAY) + + def _log_subscription_failure( + self, prefix: str, ex: Exception, *, log_traceback: bool + ) -> None: + """Log a subscription failure, throttling repeats to avoid log spam.""" + self._consecutive_subscription_failures += 1 + if self._should_log_subscription_error: + self._should_log_subscription_error = False + self.logger.error( + "%s: %s", prefix, ex, exc_info=ex if log_traceback else None + ) + elif ( + self._consecutive_subscription_failures + % _SUBSCRIPTION_FAILURE_LOG_EVERY_N_ATTEMPTS + == 0 + ): + self.logger.warning( + "Still unable to (re)connect to Traccar after %s attempts" + " (last error: %s)", + self._consecutive_subscription_failures, + ex, + exc_info=ex if log_traceback else None, + ) def _return_custom_attributes_if_not_filtered_by_accuracy_configuration( self, diff --git a/homeassistant/components/transmission/icons.json b/homeassistant/components/transmission/icons.json index 9cc3b1dfad3fd8..3173b25c1fb61d 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -12,6 +12,9 @@ "completed_torrents": { "default": "mdi:counter" }, + "download_dir_free_space": { + "default": "mdi:harddisk" + }, "download_speed": { "default": "mdi:cloud-download" }, diff --git a/homeassistant/components/twinkly/__init__.py b/homeassistant/components/twinkly/__init__.py index ca61ba4357ab8f..f43c1cd8a191c0 100644 --- a/homeassistant/components/twinkly/__init__.py +++ b/homeassistant/components/twinkly/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DEVICE_TIMEOUT, DOMAIN from .coordinator import TwinklyConfigEntry, TwinklyCoordinator PLATFORMS = [Platform.LIGHT, Platform.SELECT] @@ -25,7 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> b # we will be able to properly share the connection. host = entry.data[CONF_HOST] - client = Twinkly(host, async_get_clientsession(hass)) + client = Twinkly(host, async_get_clientsession(hass), timeout=DEVICE_TIMEOUT) coordinator = TwinklyCoordinator(hass, entry, client) @@ -47,7 +47,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> async def async_migrate_entry(hass: HomeAssistant, entry: TwinklyConfigEntry) -> bool: """Migrate old entry.""" if entry.minor_version == 1: - client = Twinkly(entry.data[CONF_HOST], async_get_clientsession(hass)) + client = Twinkly( + entry.data[CONF_HOST], + async_get_clientsession(hass), + timeout=DEVICE_TIMEOUT, + ) try: device_info = await client.get_details() except (TimeoutError, ClientError) as exception: diff --git a/homeassistant/components/twinkly/config_flow.py b/homeassistant/components/twinkly/config_flow.py index 46211cc482b97a..ecc293880a8f38 100644 --- a/homeassistant/components/twinkly/config_flow.py +++ b/homeassistant/components/twinkly/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import DEV_ID, DEV_MODEL, DEV_NAME, DOMAIN +from .const import DEV_ID, DEV_MODEL, DEV_NAME, DEVICE_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -40,7 +40,7 @@ async def async_step_user( if host is not None: try: device_info = await Twinkly( - host, async_get_clientsession(self.hass) + host, async_get_clientsession(self.hass), timeout=DEVICE_TIMEOUT ).get_details() except TimeoutError, ClientError: errors[CONF_HOST] = "cannot_connect" @@ -64,7 +64,9 @@ async def async_step_dhcp( self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) try: device_info = await Twinkly( - discovery_info.ip, async_get_clientsession(self.hass) + discovery_info.ip, + async_get_clientsession(self.hass), + timeout=DEVICE_TIMEOUT, ).get_details() except TimeoutError, ClientError: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/twinkly/const.py b/homeassistant/components/twinkly/const.py index 488b213b89549a..b17bb5ae447be3 100644 --- a/homeassistant/components/twinkly/const.py +++ b/homeassistant/components/twinkly/const.py @@ -17,3 +17,7 @@ # Minimum version required to support effects MIN_EFFECT_VERSION = "2.7.1" + +# Matches the library default, set explicitly so the integration does not +# inherit it. A device waking its radio can take several seconds to answer. +DEVICE_TIMEOUT = 10 diff --git a/homeassistant/components/twinkly/coordinator.py b/homeassistant/components/twinkly/coordinator.py index 4f0c200c9700ac..64025ca13547cf 100644 --- a/homeassistant/components/twinkly/coordinator.py +++ b/homeassistant/components/twinkly/coordinator.py @@ -1,5 +1,6 @@ """Coordinator for Twinkly.""" +from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import timedelta import logging @@ -58,8 +59,8 @@ def __init__( async def _async_setup(self) -> None: """Set up the Twinkly data.""" try: - software_version = await self.client.get_firmware_version() - self.device_name = (await self.client.get_details())[DEV_NAME] + software_version = await self._request(self.client.get_firmware_version) + self.device_name = (await self._request(self.client.get_details))[DEV_NAME] except (TimeoutError, ClientError) as exception: raise UpdateFailed from exception self.software_version = software_version["version"] @@ -67,24 +68,37 @@ async def _async_setup(self) -> None: MIN_EFFECT_VERSION ) + async def _request[_T](self, request: Callable[[], Awaitable[_T]]) -> _T: + """Make a request, retrying it once if it times out. + + A device that stalls one request keeps answering others: it replies on + a new connection within milliseconds while the first is still hanging. + aiohttp closes a timed-out connection instead of returning it to the + pool, so the retry gets a fresh one. + """ + try: + return await request() + except TimeoutError: + return await request() + @override async def _async_update_data(self) -> TwinklyData: """Fetch data from Twinkly.""" movies: list[dict[str, Any]] = [] current_movie: dict[str, Any] = {} try: - device_info = await self.client.get_details() - brightness = await self.client.get_brightness() - is_on = await self.client.is_on() - mode_data = await self.client.get_mode() + device_info = await self._request(self.client.get_details) + brightness = await self._request(self.client.get_brightness) + is_on = await self._request(self.client.is_on) + mode_data = await self._request(self.client.get_mode) current_mode = mode_data.get("mode") if self.supports_effects: - movies = (await self.client.get_saved_movies())["movies"] + movies = (await self._request(self.client.get_saved_movies))["movies"] except (TimeoutError, ClientError) as exception: raise UpdateFailed from exception if self.supports_effects: try: - current_movie = await self.client.get_current_movie() + current_movie = await self._request(self.client.get_current_movie) except (TwinklyError, TimeoutError, ClientError) as exception: _LOGGER.debug("Error fetching current movie: %s", exception) brightness = ( diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index 59e3076ab23345..00b3a5be3f7331 100644 --- a/homeassistant/components/wiim/media_player.py +++ b/homeassistant/components/wiim/media_player.py @@ -2,6 +2,8 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps +from hashlib import sha256 +import json from typing import Any, Concatenate, override from async_upnp_client.client import UpnpService, UpnpStateVariable @@ -219,12 +221,52 @@ def _clear_media_metadata(self) -> None: self._attr_media_artist = None self._attr_media_album_name = None self._attr_media_image_url = None + self._attr_media_image_hash = None self._attr_media_content_id = None self._attr_media_content_type = None self._attr_media_duration = None self._attr_media_position = None self._attr_media_position_updated_at = None + @callback + def _set_media_image_hash( + self, + *, + image_url: str | None, + media_uri: str | None, + title: str | None, + artist: str | None, + album: str | None, + ) -> None: + """Set a cache-busting media image hash for Home Assistant. + + Some WiiM sources reuse the same artwork URL across tracks, so the + default HA URL-based hash is not sufficient to invalidate the image cache. + """ + if not image_url: + self._attr_media_image_hash = None + return + + digest_source = json.dumps( + [image_url, media_uri, title, artist, album], + ensure_ascii=False, + separators=(",", ":"), + ) + self._attr_media_image_hash = sha256( + digest_source.encode("utf-8"), usedforsecurity=False + ).hexdigest() + + @override + async def async_get_media_image(self) -> tuple[bytes | None, str | None]: + """Fetch the media image using a track-aware cache key.""" + if (url := self.media_image_url) is None: + return None, None + + if (image_hash := self.media_image_hash) is not None: + url = f"{url.partition('#')[0]}#{image_hash}" + + return await self._async_fetch_image_from_cache(url) + @callback def _get_command_target_device(self, action_name: str) -> WiimDevice: """Return the device that should receive a grouped playback command.""" @@ -342,6 +384,13 @@ def _update_ha_state_from_sdk_cache( self._attr_media_artist = media.artist self._attr_media_album_name = media.album self._attr_media_image_url = media.image_url + self._set_media_image_hash( + image_url=media.image_url, + media_uri=media.uri, + title=media.title, + artist=media.artist, + album=media.album, + ) self._attr_media_content_id = media.uri self._attr_media_content_type = MediaType.MUSIC self._attr_media_duration = media.duration diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 62a2b51bdb23f0..d5e0ede81f6762 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.102"] + "requirements": ["holidays==0.103"] } diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py index 681620f6578329..d605bd272ff2f1 100644 --- a/homeassistant/components/xiaomi_ble/device_trigger.py +++ b/homeassistant/components/xiaomi_ble/device_trigger.py @@ -288,6 +288,7 @@ class TriggerModelData: "XMZNMS04LM": TRIGGER_MODEL_DATA[LOCK_FINGERPRINT], "ZNMS16LM": TRIGGER_MODEL_DATA[LOCK_FINGERPRINT], "ZNMS17LM": TRIGGER_MODEL_DATA[LOCK_FINGERPRINT], + "MJZNMS03LM": TRIGGER_MODEL_DATA[LOCK_FINGERPRINT], } diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 8457dbc0324205..84e8971f86ccc5 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -761,7 +761,13 @@ async def async_ensure_token_valid(self) -> None: if self.valid_token: return - new_token = await self.implementation.async_refresh_token(self.token) + try: + new_token = await self.implementation.async_refresh_token(self.token) + except OAuth2TokenRequestReauthError: + # Start reauth here so it also happens for callers that map the + # error onto a recoverable one, which would retry indefinitely. + self.config_entry.async_start_reauth_if_available(self.hass) + raise self.hass.config_entries.async_update_entry( self.config_entry, data={**self.config_entry.data, "token": new_token} diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index b58b02c595f779..96bc21610b77e4 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -31,7 +31,7 @@ } DEPRECATED_PACKAGES: dict[str, tuple[str, str]] = { # old_package_name: (reason, breaks_in_ha_version) - "pyserial-asyncio": ("should be replaced by pyserial-asyncio-fast", "2026.7"), + "pyserial-asyncio": ("should be replaced by serialx", "2027.2"), } _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index c7b8b0f088f462..d9bfbf76eaa40a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -420,7 +420,7 @@ aiorussound==5.0.2 aioruuvigateway==0.1.0 # homeassistant.components.shelly -aioshelly==13.29.0 +aioshelly==13.30.0 # homeassistant.components.skybell aioskybell==22.7.0 @@ -483,7 +483,7 @@ aiowithings==3.1.6 aioymaps==1.2.5 # homeassistant.components.airgradient -airgradient==0.9.2 +airgradient==0.10.0 # homeassistant.components.airly airly==1.1.0 @@ -1281,7 +1281,7 @@ hole==0.9.2 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.102 +holidays==0.103 # homeassistant.components.frontend home-assistant-frontend==20260729.7 @@ -1537,7 +1537,7 @@ lw12==0.9.2 lxml==6.1.1 # homeassistant.components.lyngdorf -lyngdorf==1.6.0 +lyngdorf==1.8.0 # homeassistant.components.matrix matrix-nio==0.26.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index a05bf10d5e549e..b395acea5e771b 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -104,9 +104,8 @@ "dataclasses-json": "be removed (it can break in Python 3.15)", # Only needed for docs "mkdocs": "not be a runtime dependency", - # Does blocking I/O and should be replaced by pyserial-asyncio-fast - # See https://github.com/home-assistant/core/pull/116635 - "pyserial-asyncio": "be replaced by pyserial-asyncio-fast", + # See https://developers.home-assistant.io/blog/2026/04/27/pyserial-to-serialx/ + "pyserial-asyncio": "be replaced by serialx", # Only needed for tests "pytest": "not be a runtime dependency", # Only needed for build diff --git a/tests/components/airgradient/__init__.py b/tests/components/airgradient/__init__.py index 9c57dbf8225e37..d10dc85871b30f 100644 --- a/tests/components/airgradient/__init__.py +++ b/tests/components/airgradient/__init__.py @@ -1,8 +1,17 @@ """Tests for the Airgradient integration.""" +from airgradient import ( + ApiVersion, + Config, + Measures, + parse_config_json, + parse_measures_json, +) + +from homeassistant.components.airgradient.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture, load_fixture async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @@ -11,3 +20,33 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + + +def load_measures_fixture(filename: str) -> Measures: + """Load and parse a legacy measures fixture.""" + return parse_measures_json( + load_fixture(filename, DOMAIN), api_version=ApiVersion.LEGACY + ) + + +def load_config_fixture(filename: str) -> Config: + """Load and parse a legacy config fixture.""" + return parse_config_json( + load_fixture(filename, DOMAIN), api_version=ApiVersion.LEGACY + ) + + +async def async_load_measures_fixture(hass: HomeAssistant, filename: str) -> Measures: + """Load and parse a legacy measures fixture asynchronously.""" + return parse_measures_json( + await async_load_fixture(hass, filename, DOMAIN), + api_version=ApiVersion.LEGACY, + ) + + +async def async_load_config_fixture(hass: HomeAssistant, filename: str) -> Config: + """Load and parse a legacy config fixture asynchronously.""" + return parse_config_json( + await async_load_fixture(hass, filename, DOMAIN), + api_version=ApiVersion.LEGACY, + ) diff --git a/tests/components/airgradient/conftest.py b/tests/components/airgradient/conftest.py index 395c5cd96a4399..bae44d353002fd 100644 --- a/tests/components/airgradient/conftest.py +++ b/tests/components/airgradient/conftest.py @@ -3,13 +3,14 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from airgradient import Config, Measures import pytest from homeassistant.components.airgradient.const import DOMAIN from homeassistant.const import CONF_HOST -from tests.common import MockConfigEntry, load_fixture +from . import load_config_fixture, load_measures_fixture + +from tests.common import MockConfigEntry @pytest.fixture @@ -37,12 +38,10 @@ def mock_airgradient_client() -> Generator[AsyncMock]: ): client = mock_client.return_value client.host = "10.0.0.131" - client.get_current_measures.return_value = Measures.from_json( - load_fixture("current_measures_indoor.json", DOMAIN) - ) - client.get_config.return_value = Config.from_json( - load_fixture("get_config_local.json", DOMAIN) + client.get_current_measures.return_value = load_measures_fixture( + "current_measures_indoor.json" ) + client.get_config.return_value = load_config_fixture("get_config_local.json") client.get_latest_firmware_version.return_value = "3.1.4" yield client @@ -52,8 +51,8 @@ def airgradient_devices( mock_airgradient_client: AsyncMock, request: pytest.FixtureRequest ) -> Generator[AsyncMock]: """Return a list of AirGradient devices.""" - mock_airgradient_client.get_current_measures.return_value = Measures.from_json( - load_fixture(f"current_measures_{request.param}.json", DOMAIN) + mock_airgradient_client.get_current_measures.return_value = load_measures_fixture( + f"current_measures_{request.param}.json" ) return mock_airgradient_client @@ -63,8 +62,8 @@ def mock_new_airgradient_client( mock_airgradient_client: AsyncMock, ) -> AsyncMock: """Mock a new AirGradient client.""" - mock_airgradient_client.get_config.return_value = Config.from_json( - load_fixture("get_config.json", DOMAIN) + mock_airgradient_client.get_config.return_value = load_config_fixture( + "get_config.json" ) return mock_airgradient_client @@ -74,8 +73,8 @@ def mock_cloud_airgradient_client( mock_airgradient_client: AsyncMock, ) -> AsyncMock: """Mock a cloud AirGradient client.""" - mock_airgradient_client.get_config.return_value = Config.from_json( - load_fixture("get_config_cloud.json", DOMAIN) + mock_airgradient_client.get_config.return_value = load_config_fixture( + "get_config_cloud.json" ) return mock_airgradient_client diff --git a/tests/components/airgradient/snapshots/test_diagnostics.ambr b/tests/components/airgradient/snapshots/test_diagnostics.ambr index 624a6f76f8d651..c8c410b4a42779 100644 --- a/tests/components/airgradient/snapshots/test_diagnostics.ambr +++ b/tests/components/airgradient/snapshots/test_diagnostics.ambr @@ -2,21 +2,32 @@ # name: test_diagnostics_polling_instance dict({ 'config': dict({ + 'back_led_brightness': None, + 'buzzer_enabled': None, + 'cloud_connection': None, 'co2_automatic_baseline_calibration_days': 8, 'configuration_control': 'local', + 'corrections': None, 'country': 'DE', 'display_brightness': 0, + 'front_led_brightness': None, + 'gps_mode': None, 'led_bar_brightness': 100, 'led_bar_mode': 'co2', + 'measurement_interval': None, 'nox_learning_offset': 12, 'pm_standard': 'ugm3', 'post_data_to_airgradient': True, 'temperature_unit': 'c', + 'touch_led_intensity': None, 'tvoc_learning_offset': 12, }), 'measures': dict({ 'ambient_temperature': 22.17, + 'battery_percentage': None, + 'battery_voltage': None, 'boot_time': 28, + 'charge_voltage': None, 'compensated_ambient_temperature': 22.17, 'compensated_pm02': None, 'compensated_relative_humidity': 47.0, @@ -24,9 +35,14 @@ 'model': 'I-9PSL', 'nitrogen_index': 1, 'pm003_count': 270, + 'pm005_count': None, 'pm01': 22, + 'pm01_count': None, 'pm02': 34.0, + 'pm02_count': None, 'pm10': 41, + 'pm10_count': None, + 'pm50_count': None, 'raw_ambient_temperature': 27.96, 'raw_nitrogen': 16931.0, 'raw_pm02': 34, diff --git a/tests/components/airgradient/test_button.py b/tests/components/airgradient/test_button.py index 9bfb4bc1a5800a..9f6f4f900553c5 100644 --- a/tests/components/airgradient/test_button.py +++ b/tests/components/airgradient/test_button.py @@ -3,26 +3,20 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientConnectionError, AirGradientError, Config +from airgradient import AirGradientConnectionError, AirGradientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import async_load_config_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -80,8 +74,8 @@ async def test_cloud_creates_no_button( assert len(hass.states.async_all()) == 0 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_local.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_local.json") ) freezer.tick(timedelta(minutes=5)) @@ -90,8 +84,8 @@ async def test_cloud_creates_no_button( assert len(hass.states.async_all()) == 2 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_cloud.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_cloud.json") ) freezer.tick(timedelta(minutes=5)) diff --git a/tests/components/airgradient/test_number.py b/tests/components/airgradient/test_number.py index 3fc6a16787f6dc..6dbe74575b4368 100644 --- a/tests/components/airgradient/test_number.py +++ b/tests/components/airgradient/test_number.py @@ -3,12 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientConnectionError, AirGradientError, Config +from airgradient import AirGradientConnectionError, AirGradientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -19,14 +18,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import async_load_config_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -82,8 +76,8 @@ async def test_cloud_creates_no_number( assert len(hass.states.async_all()) == 0 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_local.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_local.json") ) freezer.tick(timedelta(minutes=5)) @@ -92,8 +86,8 @@ async def test_cloud_creates_no_number( assert len(hass.states.async_all()) == 2 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_cloud.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_cloud.json") ) freezer.tick(timedelta(minutes=5)) diff --git a/tests/components/airgradient/test_select.py b/tests/components/airgradient/test_select.py index feb7e78bb211a2..46d14c4b35dc7d 100644 --- a/tests/components/airgradient/test_select.py +++ b/tests/components/airgradient/test_select.py @@ -3,12 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientConnectionError, AirGradientError, Config +from airgradient import AirGradientConnectionError, AirGradientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, @@ -18,14 +17,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import async_load_config_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -76,8 +70,8 @@ async def test_cloud_creates_no_number( assert len(hass.states.async_all()) == 1 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_local.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_local.json") ) freezer.tick(timedelta(minutes=5)) @@ -86,8 +80,8 @@ async def test_cloud_creates_no_number( assert len(hass.states.async_all()) == 7 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_cloud.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_cloud.json") ) freezer.tick(timedelta(minutes=5)) diff --git a/tests/components/airgradient/test_sensor.py b/tests/components/airgradient/test_sensor.py index 5c2976b97ef6b9..7a0eb6e8b2f570 100644 --- a/tests/components/airgradient/test_sensor.py +++ b/tests/components/airgradient/test_sensor.py @@ -3,24 +3,18 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientError, Measures +from airgradient import AirGradientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import async_load_measures_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -45,15 +39,15 @@ async def test_create_entities( freezer: FrozenDateTimeFactory, ) -> None: """Test creating entities.""" - mock_airgradient_client.get_current_measures.return_value = Measures.from_json( - await async_load_fixture(hass, "measures_after_boot.json", DOMAIN) + mock_airgradient_client.get_current_measures.return_value = ( + await async_load_measures_fixture(hass, "measures_after_boot.json") ) with patch("homeassistant.components.airgradient.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - mock_airgradient_client.get_current_measures.return_value = Measures.from_json( - await async_load_fixture(hass, "current_measures_indoor.json", DOMAIN) + mock_airgradient_client.get_current_measures.return_value = ( + await async_load_measures_fixture(hass, "current_measures_indoor.json") ) freezer.tick(timedelta(minutes=1)) async_fire_time_changed(hass) diff --git a/tests/components/airgradient/test_switch.py b/tests/components/airgradient/test_switch.py index 2145dc21496a0f..9289d71b204734 100644 --- a/tests/components/airgradient/test_switch.py +++ b/tests/components/airgradient/test_switch.py @@ -3,12 +3,11 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from airgradient import AirGradientConnectionError, AirGradientError, Config +from airgradient import AirGradientConnectionError, AirGradientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.airgradient.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -20,14 +19,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import async_load_config_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_all_entities( @@ -82,8 +76,8 @@ async def test_cloud_creates_no_switch( assert len(hass.states.async_all()) == 0 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_local.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_local.json") ) freezer.tick(timedelta(minutes=5)) @@ -92,8 +86,8 @@ async def test_cloud_creates_no_switch( assert len(hass.states.async_all()) == 1 - mock_cloud_airgradient_client.get_config.return_value = Config.from_json( - await async_load_fixture(hass, "get_config_cloud.json", DOMAIN) + mock_cloud_airgradient_client.get_config.return_value = ( + await async_load_config_fixture(hass, "get_config_cloud.json") ) freezer.tick(timedelta(minutes=5)) diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6d037a66e72308..0f41a6e0f06bc0 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -1,6 +1,7 @@ """Tests for the Bond light device.""" from datetime import timedelta +from unittest.mock import call from bond_async import Action, DeviceType import pytest @@ -17,6 +18,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, + ATTR_COLOR_TEMP_KELVIN, ATTR_SUPPORTED_COLOR_MODES, DOMAIN as LIGHT_DOMAIN, ColorMode, @@ -80,6 +82,20 @@ def dimmable_ceiling_fan(name: str): } +def dimmable_color_temp_ceiling_fan(name: str): + """Create a ceiling fan (that has built-in CCT light) with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": [ + Action.TURN_LIGHT_ON, + Action.TURN_LIGHT_OFF, + Action.SET_BRIGHTNESS, + Action.SET_COLOR_TEMP, + ], + } + + def down_light_ceiling_fan(name: str): """Create a ceiling fan (that has built-in down light) with given name.""" return { @@ -799,6 +815,66 @@ async def test_turn_on_light_with_brightness(hass: HomeAssistant) -> None: ) +async def test_color_temp_support(hass: HomeAssistant) -> None: + """Tests that a dimmable CCT light should support the color temperature feature.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_color_temp_ceiling_fan("name-1"), + bond_device_id="test-device-id", + props={"min_color_temp_kelvin": 2700, "max_color_temp_kelvin": 5000}, + ) + + state = hass.states.get("light.name_1") + assert state.state == "off" + assert state.attributes[ATTR_COLOR_MODE] is None + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + with patch_bond_device_state( + return_value={"light": 1, "brightness": 50, "color_temp": 3000} + ): + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + + state = hass.states.get("light.name_1") + assert state.state == "on" + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert state.attributes[ATTR_COLOR_TEMP_KELVIN] == 3000 + + +async def test_turn_on_light_with_color(hass: HomeAssistant) -> None: + """Tests turning on a dimmable CCT light delegates to API and parses brightness + color temp.""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_color_temp_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_set_color, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.name_1", + ATTR_BRIGHTNESS: 128, + ATTR_COLOR_TEMP_KELVIN: 4000, + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_color.assert_has_calls( + calls=[ + call("test-device-id", Action(Action.SET_BRIGHTNESS, 50)), + call("test-device-id", Action(Action.SET_COLOR_TEMP, 4000)), + ] + ) + + async def test_turn_on_up_light(hass: HomeAssistant) -> None: """Tests that turn on command, on an up light, delegates to API.""" await setup_platform( @@ -1038,3 +1114,26 @@ async def test_parse_brightness(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.states.get("light.name_1").attributes[ATTR_BRIGHTNESS] == 128 + + +async def test_parse_color_temp(hass: HomeAssistant) -> None: + """Tests that HA color temp converts to nearest 100K (max precision of Bond API).""" + await setup_platform( + hass, + LIGHT_DOMAIN, + dimmable_color_temp_ceiling_fan("name-1"), + bond_device_id="test-device-id", + ) + + with patch_bond_action() as mock_set_color, patch_bond_device_state(): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.name_1", ATTR_COLOR_TEMP_KELVIN: 3250}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_set_color.assert_called_once_with( + "test-device-id", Action.set_color_temperature(3200) + ) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index fcf3212f06081a..e4dd8c21ce7edd 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -540,6 +540,60 @@ async def test_device_rename_refreshes_slot_list( assert len(calls) == 1 +@pytest.mark.usefixtures("init_components") +async def test_entity_moved_to_device_refreshes_slot_list( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test moving an entity to another device updates its matchable computed name.""" + config_entry = MockConfigEntry() + config_entry.add_to_hass(hass) + kitchen = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=set(), + identifiers={("demo", "kitchen")}, + name="Kitchen", + ) + bedroom = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections=set(), + identifiers={("demo", "bedroom")}, + name="Bedroom", + ) + + light = entity_registry.async_get_or_create( + "light", + "demo", + "1234", + device_id=kitchen.id, + has_entity_name=True, + original_name="Light", + ) + hass.states.async_set(light.entity_id, "off") + expose_entity(hass, light.entity_id, True) + + # Populate the slot list cache: the current computed name matches. + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on Kitchen Light", None, Context(), None + ) + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + + # Moving the light to the bedroom changes its computed name to "Bedroom Light". + entity_registry.async_update_entity(light.entity_id, device_id=bedroom.id) + await hass.async_block_till_done() + + # The new name is now matchable. + calls = async_mock_service(hass, "light", "turn_on") + result = await conversation.async_converse( + hass, "turn on Bedroom Light", None, Context(), None + ) + assert result.response.response_type is intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + + @pytest.mark.usefixtures("init_components") async def test_trigger_sentences(hass: HomeAssistant) -> None: """Test registering/unregistering/matching a few trigger sentences.""" diff --git a/tests/components/lunatone/conftest.py b/tests/components/lunatone/conftest.py index ba5e5f364b9c13..82813f857cc68f 100644 --- a/tests/components/lunatone/conftest.py +++ b/tests/components/lunatone/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch from lunatone_rest_api_client import Device, Devices, Info, Sensor, Sensors -from lunatone_rest_api_client.models import InfoData, SensorsData +from lunatone_rest_api_client.models import InfoData, ScanData, ScanState, SensorsData import pytest from homeassistant.components.lunatone.config_flow import LunatoneConfigFlow @@ -161,6 +161,29 @@ def _set_data(data: SensorsData) -> None: yield sensors +@pytest.fixture +def mock_lunatone_scan() -> Generator[AsyncMock]: + """Mock a Lunatone DALI scan object.""" + with ( + patch( + "homeassistant.components.lunatone.DALIScan", + autospec=True, + ) as mock_dali_scan, + patch( + "homeassistant.components.lunatone.coordinator.DALIScan", + new=mock_dali_scan, + ), + ): + scan = mock_dali_scan.return_value + scan.data = ScanData() + type(scan).is_busy = PropertyMock( + side_effect=lambda: ( + scan.data.status in {ScanState.ADDRESSING, ScanState.IN_PROGRESS} + ) + ) + yield scan + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/lunatone/snapshots/test_binary_sensor.ambr b/tests/components/lunatone/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..c6c2547af67049 --- /dev/null +++ b/tests/components/lunatone/snapshots/test_binary_sensor.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_setup[binary_sensor.test_dali_scan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_dali_scan', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DALI scan', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DALI scan', + 'platform': 'lunatone', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'scan_status', + 'unique_id': 'be37ca9c47c24498a38bc62c7c711840-scan-progress', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[binary_sensor.test_dali_scan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'running', + : 'Test DALI scan', + }), + 'context': , + 'entity_id': 'binary_sensor.test_dali_scan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lunatone/test_binary_sensor.py b/tests/components/lunatone/test_binary_sensor.py new file mode 100644 index 00000000000000..ae9a13025e4de2 --- /dev/null +++ b/tests/components/lunatone/test_binary_sensor.py @@ -0,0 +1,76 @@ +"""Tests for the binary sensors provided by the Lunatone integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from lunatone_rest_api_client.models import ScanData, ScanState +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Lunatone binary sensor setup.""" + await setup_integration(hass, mock_config_entry) + + entities = hass.states.async_all(Platform.BINARY_SENSOR) + for entity_state in entities: + entity_entry = entity_registry.async_get(entity_state.entity_id) + assert entity_entry + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def test_sensor_value_update( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone DALI scan status value update.""" + scan_states = iter((ScanState.ADDRESSING, ScanState.DONE)) + + await setup_integration(hass, mock_config_entry) + + coordinator = mock_config_entry.runtime_data.coordinator_scan + + async def fake_update(): + scan_state = next(scan_states) + mock_lunatone_scan.data = ScanData(status=scan_state) + + mock_lunatone_scan.async_update.side_effect = fake_update + + entities = hass.states.async_all(Platform.BINARY_SENSOR) + assert entities[0].state == "off" + assert coordinator.update_interval == timedelta(seconds=10) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + entities = hass.states.async_all(Platform.BINARY_SENSOR) + assert entities[0].state == "on" + assert coordinator.update_interval == timedelta(seconds=1) + + await coordinator.async_refresh() + await hass.async_block_till_done() + + entities = hass.states.async_all(Platform.BINARY_SENSOR) + assert entities[0].state == "off" + assert coordinator.update_interval == timedelta(seconds=10) diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index 3db320d2a50757..16fc432f531f4f 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -158,6 +158,7 @@ async def test_zeroconf_flow( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, ) -> None: """Test zeroconf flow.""" result = await hass.config_entries.flow.async_init( @@ -180,6 +181,7 @@ async def test_zeroconf_flow_abort_duplicate( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test zeroconf flow aborts with duplicate.""" diff --git a/tests/components/lunatone/test_diagnostics.py b/tests/components/lunatone/test_diagnostics.py index 044ff2d30af590..fbf0e42995bbe7 100644 --- a/tests/components/lunatone/test_diagnostics.py +++ b/tests/components/lunatone/test_diagnostics.py @@ -19,6 +19,7 @@ async def test_config_entry_diagnostics( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: diff --git a/tests/components/lunatone/test_init.py b/tests/components/lunatone/test_init.py index 0f644d905ea80f..9c0b5275b3fc89 100644 --- a/tests/components/lunatone/test_init.py +++ b/tests/components/lunatone/test_init.py @@ -20,6 +20,7 @@ async def test_load_unload_config_entry( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, ) -> None: @@ -50,6 +51,7 @@ async def test_config_entry_not_ready_info_api_fail( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config entry not ready due to info API failure.""" @@ -74,6 +76,7 @@ async def test_config_entry_not_ready_devices_api_fail( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config entry not ready due to devices API failure.""" @@ -100,6 +103,7 @@ async def test_config_entry_not_ready_sensors_api_fail( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test config entry not ready due to sensors API failure.""" @@ -123,6 +127,37 @@ async def test_config_entry_not_ready_sensors_api_fail( assert mock_config_entry.state is ConfigEntryState.LOADED +async def test_config_entry_not_ready_scan_api_fail( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry not ready due to sensors API failure.""" + mock_lunatone_scan.async_update.side_effect = aiohttp.ClientConnectionError() + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + mock_lunatone_sensors.async_update.assert_called_once() + mock_lunatone_scan.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + mock_lunatone_scan.async_update.side_effect = None + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_lunatone_info.async_update.assert_called() + mock_lunatone_devices.async_update.assert_called() + mock_lunatone_sensors.async_update.assert_called() + mock_lunatone_scan.async_update.assert_called() + assert mock_config_entry.state is ConfigEntryState.LOADED + + async def test_config_entry_not_ready_no_info_data( hass: HomeAssistant, mock_lunatone_info: AsyncMock, @@ -172,6 +207,26 @@ async def test_config_entry_not_ready_no_sensors_data( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +async def test_config_entry_not_ready_no_dali_scan_data( + hass: HomeAssistant, + mock_lunatone_info: AsyncMock, + mock_lunatone_devices: AsyncMock, + mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Lunatone configuration entry not ready due to missing DALI scan data.""" + mock_lunatone_scan.data = None + + await setup_integration(hass, mock_config_entry) + + mock_lunatone_info.async_update.assert_called_once() + mock_lunatone_devices.async_update.assert_called_once() + mock_lunatone_sensors.async_update.assert_called_once() + mock_lunatone_scan.async_update.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_config_entry_not_ready_no_serial_number( hass: HomeAssistant, mock_lunatone_info: AsyncMock, @@ -192,6 +247,7 @@ async def test_config_entry_unique_id_update( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, diff --git a/tests/components/lunatone/test_light.py b/tests/components/lunatone/test_light.py index 4ccbf1964335b5..0b88287f49eb04 100644 --- a/tests/components/lunatone/test_light.py +++ b/tests/components/lunatone/test_light.py @@ -35,6 +35,7 @@ async def test_setup( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -56,6 +57,7 @@ async def test_turn_on_off( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test the light can be turned on and off.""" @@ -98,6 +100,7 @@ async def test_turn_on_off_with_brightness( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test the light can be turned on with brightness.""" @@ -158,6 +161,7 @@ async def test_turn_on_off_broadcast( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_lunatone_dali_broadcast: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -202,6 +206,7 @@ async def test_line_broadcast_available_status( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_lunatone_dali_broadcast: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -234,6 +239,7 @@ async def test_line_broadcast_line_present( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_lunatone_dali_broadcast: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: @@ -254,6 +260,7 @@ async def test_turn_on_with_color_temperature( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, color_temp_kelvin: int, ) -> None: @@ -294,6 +301,7 @@ async def test_turn_on_with_rgb_color( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, rgb_color: tuple[int, int, int], ) -> None: @@ -336,6 +344,7 @@ async def test_turn_on_with_rgbw_color( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, rgbw_color: tuple[int, int, int, int], ) -> None: diff --git a/tests/components/lunatone/test_sensor.py b/tests/components/lunatone/test_sensor.py index a630aa8fc06a02..cccfb2f58fa494 100644 --- a/tests/components/lunatone/test_sensor.py +++ b/tests/components/lunatone/test_sensor.py @@ -21,6 +21,7 @@ async def test_setup( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, @@ -41,6 +42,7 @@ async def test_sensor_value_update( mock_lunatone_info: AsyncMock, mock_lunatone_devices: AsyncMock, mock_lunatone_sensors: AsyncMock, + mock_lunatone_scan: AsyncMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 88e10dde19b380..27f420c818dea0 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -52,6 +52,86 @@ def mock_browse_by_idstring( return None +@pytest.mark.parametrize( + ("idstring", "expected_type"), + [ + pytest.param("A:ALBUM/Abbey%20Road", MediaType.ALBUM, id="album"), + pytest.param( + "A:ALBUMARTIST/The%20Beatles", MediaType.ARTIST, id="album_artist" + ), + ], +) +def test_build_item_response_container_art_uses_media_type( + idstring: str, expected_type: MediaType +) -> None: + """Test container art is requested with a MediaType, not a Sonos search type. + + async_get_browse_image matches on MediaType, so requesting the container art + with the Sonos search type makes the browse image proxy return no image. + """ + music_library = MagicMock() + music_library.browse_by_idstring.return_value = [ + MockMusicServiceItem( + "Come Together", + "x-file-cifs://192.168.42.10/music/01%20Come%20Together.mp3", + idstring, + "object.item.audioItem.musicTrack", + ) + ] + music_library.get_music_library_information.return_value = [] + get_thumbnail_url = Mock(return_value="/thumb") + + build_item_response( + music_library, + {"search_type": MediaType.ALBUM, "idstring": idstring}, + get_thumbnail_url, + ) + + assert get_thumbnail_url.call_args.args[0] == expected_type + + +@pytest.mark.parametrize( + ("idstring", "child_class", "expected_can_play"), + [ + pytest.param( + "A:ALBUM/Abbey%20Road", + "object.item.audioItem.musicTrack", + True, + id="single_album", + ), + pytest.param( + "A:ALBUM", + "object.container.album.musicAlbum", + False, + id="album_listing", + ), + ], +) +def test_build_item_response_playable_only_for_a_single_album( + idstring: str, child_class: str, expected_can_play: bool +) -> None: + """Test a resolved album is playable while the album listing is not. + + can_play is passed a Sonos search type, which for the listing would otherwise + mark every library listing playable. + """ + music_library = MagicMock() + music_library.browse_by_idstring.return_value = [ + MockMusicServiceItem( + "Abbey Road", "A:ALBUM/Abbey%20Road", idstring, child_class + ) + ] + music_library.get_music_library_information.return_value = [] + + response = build_item_response( + music_library, + {"search_type": MediaType.ALBUM, "idstring": idstring}, + Mock(return_value="/thumb"), + ) + + assert response.can_play is expected_can_play + + async def test_build_item_response( hass: HomeAssistant, soco_factory: SoCoMockFactory, diff --git a/tests/components/spotify/test_init.py b/tests/components/spotify/test_init.py index e20a25fa11e160..fc724acd9aa561 100644 --- a/tests/components/spotify/test_init.py +++ b/tests/components/spotify/test_init.py @@ -1,12 +1,13 @@ """Tests for the Spotify initialization.""" +from http import HTTPStatus from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError, SpotifyForbiddenError from homeassistant.components.spotify.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.config_entry_oauth2_flow import ( @@ -16,6 +17,9 @@ from . import setup_integration from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +TOKEN_URL = "https://accounts.spotify.com/api/token" @pytest.mark.usefixtures("setup_credentials") @@ -92,3 +96,28 @@ async def test_oauth_implementation_not_available( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("setup_credentials", "mock_spotify") +@pytest.mark.parametrize("expires_at", [0], ids=["expired"]) +async def test_revoked_refresh_token_starts_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test a revoked refresh token asks the user to reauthenticate. + + Setup maps the failure onto ConfigEntryNotReady, so without starting reauth + the entry would keep retrying a token that can never be refreshed again. + """ + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.BAD_REQUEST, json={}) + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["handler"] == DOMAIN + assert flows[0]["context"]["source"] == SOURCE_REAUTH diff --git a/tests/components/statistics/test_config_flow.py b/tests/components/statistics/test_config_flow.py index 05c19f580e723d..e0299dee904964 100644 --- a/tests/components/statistics/test_config_flow.py +++ b/tests/components/statistics/test_config_flow.py @@ -22,7 +22,7 @@ ) from homeassistant.const import CONF_ENTITY_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator @@ -77,6 +77,53 @@ async def test_form_sensor(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_sampling_size_zero_rejected( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the options step rejects a sampling_size of 0.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + }, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STATE_CHARACTERISTIC: STAT_VALUE_MAX, + }, + ) + await hass.async_block_till_done() + + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 0, + CONF_MAX_AGE: {"hours": 1, "minutes": 0, "seconds": 0}, + }, + ) + + # A valid sampling size finalizes the flow successfully. + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_SAMPLES_MAX_BUFFER_SIZE: 1, + CONF_MAX_AGE: {"hours": 1, "minutes": 0, "seconds": 0}, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["options"][CONF_SAMPLES_MAX_BUFFER_SIZE] == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_form_binary_sensor( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index ca2c375eb565b9..df37c994d3a689 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -384,7 +384,39 @@ async def test_migration_1_1( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id assert statistics_config_entry.version == 1 - assert statistics_config_entry.minor_version == 2 + assert statistics_config_entry.minor_version == 3 + + +async def test_migration_1_2_removes_zero_sampling_size( + hass: HomeAssistant, +) -> None: + """Test migration from v1.2 removes a sampling size of 0 from the options.""" + statistics_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": "sensor.test", + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 0.0, + "max_age": {"hours": 1, "minutes": 0, "seconds": 0}, + }, + title="My statistics", + version=1, + minor_version=2, + ) + statistics_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + # The invalid sampling size of 0 must be removed and the entry migrated. + assert "sampling_size" not in statistics_config_entry.options + assert statistics_config_entry.version == 1 + assert statistics_config_entry.minor_version == 3 async def test_migration_from_future_version( diff --git a/tests/components/switchbot_cloud/test_light.py b/tests/components/switchbot_cloud/test_light.py index 08e47c0b899c1c..6fc5f9be7236d6 100644 --- a/tests/components/switchbot_cloud/test_light.py +++ b/tests/components/switchbot_cloud/test_light.py @@ -1,9 +1,15 @@ """Test for the Switchbot Light Entity.""" -from unittest.mock import patch +from unittest.mock import call, patch import pytest -from switchbot_api import CeilingLightCommands, CommonCommands, Device, SwitchBotAPI +from switchbot_api import ( + CeilingLightCommands, + CommonCommands, + Device, + RGBWWLightCommands, + SwitchBotAPI, +) from homeassistant.components.light import ( ATTR_COLOR_MODE, @@ -548,3 +554,158 @@ async def test_candle_warmer_lamp( mock_send_command.assert_called() state = hass.states.get(entity_id) assert state.state is STATE_ON + + +async def test_rgbww_light_brightness_and_color_temp( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test RGBWW light turn on with both brightness and color temperature.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + {"power": "on", "brightness": 38, "color": "0:0:0", "colorTemperature": 3000}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + "brightness": 99, + "color_temp_kelvin": 3000, + }, + blocking=True, + ) + assert mock_send_command.call_count == 2 + mock_send_command.assert_has_calls( + [ + call("light-id-1", RGBWWLightCommands.SET_BRIGHTNESS, "command", "38"), + call( + "light-id-1", + RGBWWLightCommands.SET_COLOR_TEMPERATURE, + "command", + "3000", + ), + ] + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP + + +async def test_rgbww_light_brightness_and_rgb_color( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test RGBWW light turn on with both brightness and RGB color.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType="Strip Light 3", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "color": "0:0:0", "colorTemperature": 4567}, + { + "power": "on", + "brightness": 38, + "color": "255:246:158", + "colorTemperature": 4567, + }, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + "brightness": 99, + "rgb_color": (255, 246, 158), + }, + blocking=True, + ) + assert mock_send_command.call_count == 2 + mock_send_command.assert_has_calls( + [ + call("light-id-1", RGBWWLightCommands.SET_BRIGHTNESS, "command", "38"), + call( + "light-id-1", + RGBWWLightCommands.SET_COLOR, + "command", + "255:246:158", + ), + ] + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.RGB + + +@pytest.mark.parametrize("device_type", ["Ceiling Light", "Ceiling Light Pro"]) +async def test_ceiling_light_brightness_and_color_temp( + hass: HomeAssistant, mock_list_devices, mock_get_status, device_type +) -> None: + """Test ceiling light turn on with both brightness and color temperature.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="light-id-1", + deviceName="light-1", + deviceType=device_type, + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "brightness": 1, "colorTemperature": 4567}, + {"power": "on", "brightness": 38, "colorTemperature": 3000}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "light.light_1" + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: entity_id, + "brightness": 99, + "color_temp_kelvin": 3000, + }, + blocking=True, + ) + assert mock_send_command.call_count == 2 + mock_send_command.assert_has_calls( + [ + call( + "light-id-1", CeilingLightCommands.SET_BRIGHTNESS, "command", "38" + ), + call( + "light-id-1", + CeilingLightCommands.SET_COLOR_TEMPERATURE, + "command", + "3000", + ), + ] + ) + state = hass.states.get(entity_id) + assert state.state is STATE_ON + assert state.attributes[ATTR_COLOR_MODE] == ColorMode.COLOR_TEMP diff --git a/tests/components/traccar_server/__init__.py b/tests/components/traccar_server/__init__.py index 7b7a59d3b61b5b..479400f33b3697 100644 --- a/tests/components/traccar_server/__init__.py +++ b/tests/components/traccar_server/__init__.py @@ -1 +1,17 @@ """Tests for the Traccar Server integration.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +from pytraccar import SubscriptionData + + +def get_subscription_callback( + mock_traccar_api_client: AsyncMock, +) -> Callable[[SubscriptionData], Awaitable[None]]: + """Return the callback our integration registered with client.subscribe(). + + Reading it off the mock's call args exercises the exact function + pytraccar would invoke, instead of calling a coordinator method by name. + """ + return mock_traccar_api_client.subscribe.call_args.args[0] diff --git a/tests/components/traccar_server/test_device_tracker.py b/tests/components/traccar_server/test_device_tracker.py new file mode 100644 index 00000000000000..30efdd718a6857 --- /dev/null +++ b/tests/components/traccar_server/test_device_tracker.py @@ -0,0 +1,124 @@ +"""Test the Traccar Server device tracker.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import get_subscription_callback +from .common import setup_integration + +from tests.common import MockConfigEntry + + +async def test_update_data_happy_path( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Devices, positions, and geofences merged by the coordinator reach the device tracker.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("device_tracker.x_wing") + assert state is not None + assert state.attributes["latitude"] == 52.0 + assert state.attributes["longitude"] == 25.0 + assert state.attributes["gps_accuracy"] == 3.5 + # accuracy (3.5) is below max_accuracy (5.0), so the custom attribute + # should be included rather than filtered out. + assert state.attributes["custom_attr_1"] == "custom_attr_1_value" + + +async def test_handle_subscription_data_updates_known_device( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A subscription update for a known device updates its device tracker state.""" + await setup_integration(hass, mock_config_entry) + subscription_callback = get_subscription_callback(mock_traccar_api_client) + + updated_position = { + "id": 0, + "deviceId": 0, + "latitude": 60.0, + "longitude": 30.0, + "accuracy": 3.5, + "address": "Mos Eisley", + "attributes": {"custom_attr_1": "custom_attr_1_value"}, + } + + await subscription_callback( + {"devices": None, "events": None, "positions": [updated_position]} + ) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.x_wing") + assert state.attributes["latitude"] == 60.0 + assert state.attributes["longitude"] == 30.0 + + +async def test_handle_subscription_data_ignores_unknown_device( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Subscription data for a device we haven't seen via polling is ignored.""" + await setup_integration(hass, mock_config_entry) + subscription_callback = get_subscription_callback(mock_traccar_api_client) + + state_before = hass.states.get("device_tracker.x_wing") + + unknown_position = { + "id": 999, + "deviceId": 999, + "latitude": 60.0, + "longitude": 30.0, + "accuracy": 3.5, + "address": "Mos Eisley", + "attributes": {}, + } + + # Should not raise, and should not touch the known device's state. + await subscription_callback( + {"devices": None, "events": None, "positions": [unknown_position]} + ) + await hass.async_block_till_done() + + state_after = hass.states.get("device_tracker.x_wing") + assert state_after.state == state_before.state + assert state_after.attributes == state_before.attributes + assert hass.states.get("device_tracker.unknown_999") is None + + +async def test_handle_subscription_data_filters_low_accuracy_position( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A position update that fails the accuracy filter is skipped.""" + await setup_integration(hass, mock_config_entry) + subscription_callback = get_subscription_callback(mock_traccar_api_client) + + original_latitude = hass.states.get("device_tracker.x_wing").attributes["latitude"] + + poor_accuracy_position = { + "id": 0, + "deviceId": 0, + "latitude": 60.0, + "longitude": 30.0, + # max_accuracy for this config entry is 5.0. + "accuracy": 999.0, + "address": "Should not be applied", + "attributes": {"custom_attr_1": "custom_attr_1_value"}, + } + + await subscription_callback( + {"devices": None, "events": None, "positions": [poor_accuracy_position]} + ) + await hass.async_block_till_done() + + state = hass.states.get("device_tracker.x_wing") + assert state.attributes["latitude"] == original_latitude diff --git a/tests/components/traccar_server/test_init.py b/tests/components/traccar_server/test_init.py new file mode 100644 index 00000000000000..5eaf7271227b74 --- /dev/null +++ b/tests/components/traccar_server/test_init.py @@ -0,0 +1,357 @@ +"""Test the Traccar Server integration setup and subscription lifecycle.""" + +import asyncio +from collections.abc import Awaitable, Callable +from datetime import timedelta +import logging +import sys +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pytraccar import SubscriptionData, TraccarAuthenticationException, TraccarException + +from homeassistant.components.traccar_server.coordinator import ( + _SUBSCRIPTION_RECONNECT_DELAY, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed + +from .common import setup_integration + +from tests.common import MockConfigEntry, async_capture_events, async_fire_time_changed + + +async def test_update_data_auth_failure_triggers_reauth( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """An auth failure during update should fail setup and prompt reauth.""" + mock_traccar_api_client.get_devices.side_effect = TraccarAuthenticationException( + "Unauthorized" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert any( + flow["context"]["source"] == "reauth" + for flow in hass.config_entries.flow.async_progress() + ) + + +async def test_update_data_traccar_exception_retries_setup( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A non-auth error during update should leave setup pending retry.""" + mock_traccar_api_client.get_positions.side_effect = TraccarException( + "Simulated server error" + ) + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_handle_subscription_data_logs_restored_after_failures( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Receiving data after failures logs a restored message and resets the counter.""" + calls = 0 + + async def _fail_three_times_then_succeed( + callback: Callable[[SubscriptionData], Awaitable[None]], + ) -> None: + nonlocal calls + calls += 1 + if calls <= 3: + raise TraccarException("Simulated dropped connection") + await callback({"devices": None, "events": None, "positions": None}) + raise asyncio.CancelledError + + mock_traccar_api_client.subscribe = AsyncMock( + side_effect=_fail_three_times_then_succeed + ) + + with ( + patch( + "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", + 0, + ), + caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), + ): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert any( + "connection restored after 3 failed attempt(s)" in r.message + for r in info_records + ) + + +async def test_import_events_fires_hass_events( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Events returned by the Traccar API are imported on schedule and fired on the HA bus.""" + events = async_capture_events(hass, "traccar_device_moving") + + await setup_integration(hass, mock_config_entry) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data["device_traccar_id"] == 0 + assert events[0].data["device_name"] == "X-Wing" + assert events[0].data["type"] == "deviceMoving" + + +async def test_subscribe_raises_config_entry_auth_failed( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """An authentication failure must stop retrying, not loop forever.""" + ready_to_raise = asyncio.Event() + + async def _raise_auth_failure_when_ready(_callback: object) -> None: + await ready_to_raise.wait() + raise TraccarAuthenticationException("Unauthorized") + + mock_traccar_api_client.subscribe = AsyncMock( + side_effect=_raise_auth_failure_when_ready + ) + + await setup_integration(hass, mock_config_entry) + + # The background task the integration created is still pending here + # (it's blocked on ready_to_raise), so it's still tracked on the entry. + background_tasks = list(mock_config_entry._background_tasks) + assert len(background_tasks) == 1 + + ready_to_raise.set() + with pytest.raises(ConfigEntryAuthFailed): + await background_tasks[0] + + # A retryable failure would call subscribe() again after the reconnect + # delay; an auth failure must not retry at all. + assert mock_traccar_api_client.subscribe.call_count == 1 + + +async def test_subscribe_does_not_recurse_across_reconnects( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Subscribe retries must not grow the call stack.""" + attempts = 0 + target_attempts = sys.getrecursionlimit() * 2 + + async def _flaky_subscribe(_callback: object) -> None: + nonlocal attempts + attempts += 1 + if attempts >= target_attempts: + # End the task deterministically, the same way an unload would. + raise asyncio.CancelledError + raise TraccarException("Simulated dropped connection") + + mock_traccar_api_client.subscribe = AsyncMock(side_effect=_flaky_subscribe) + + with patch( + "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", + 0, + ): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + assert attempts == target_attempts + + +async def test_subscribe_does_not_busy_loop_on_clean_return( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """If client.subscribe() ever returns without raising, still throttle. + + pytraccar's subscribe() should always raise on disconnect (see + pytraccar#477), so a clean return isn't expected in practice. But the + retry loop must not assume that - if it ever happens, reconnecting + immediately with no delay would spin the event loop at 100% CPU. + """ + calls = 0 + + async def _clean_return_then_cancel(_callback: object) -> None: + nonlocal calls + calls += 1 + if calls >= 3: + raise asyncio.CancelledError + + mock_traccar_api_client.subscribe = AsyncMock(side_effect=_clean_return_then_cancel) + + with patch( + "homeassistant.components.traccar_server.coordinator.asyncio.sleep", + new=AsyncMock(), + ) as mock_sleep: + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + assert calls == 3 + # Only calls 1 and 2 (the clean returns) reach the loop's delay; + # call 3 raises CancelledError before that line, so exactly two real + # reconnect delays are attributable to this code path. + reconnect_delay_sleeps = [ + call + for call in mock_sleep.await_args_list + if call.args == (_SUBSCRIPTION_RECONNECT_DELAY,) + ] + assert len(reconnect_delay_sleeps) == 2 + + +async def test_subscribe_retries_on_unexpected_exception( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """An exception that isn't a TraccarException must still be retried. + + pytraccar's own exceptions all subclass TraccarException, but an + unrecognized failure could still slip through as something else - + that must not be allowed to escape the retry loop. + """ + calls = 0 + + async def _weird_failure_then_cancel(_callback: object) -> None: + nonlocal calls + calls += 1 + if calls >= 3: + raise asyncio.CancelledError + # Something that is NOT a TraccarException - e.g. a raw error + # that slipped through pytraccar's own exception wrapping. + raise ValueError("Simulated unexpected failure") + + mock_traccar_api_client.subscribe = AsyncMock( + side_effect=_weird_failure_then_cancel + ) + + with patch( + "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", + 0, + ): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + assert calls == 3 + + +async def test_subscribe_logs_error_once_then_periodic_reminder( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """The first failure logs an error; later failures throttle to a periodic warning.""" + calls = 0 + target_attempts = 61 # Crosses two 30-attempt reminder boundaries (30, 60). + + async def _always_fails(_callback: object) -> None: + nonlocal calls + calls += 1 + if calls >= target_attempts: + raise asyncio.CancelledError + raise TraccarException("Simulated dropped connection") + + mock_traccar_api_client.subscribe = AsyncMock(side_effect=_always_fails) + + with ( + patch( + "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", + 0, + ), + caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), + ): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + error_records = [ + r + for r in caplog.records + if r.levelno == logging.ERROR + and r.name == "homeassistant.components.traccar_server" + ] + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING + and r.name == "homeassistant.components.traccar_server" + ] + + assert len(error_records) == 1 + assert "Error while subscribing to Traccar" in error_records[0].message + assert len(warning_records) == 2 + assert all( + "Still unable to (re)connect to Traccar" in r.message for r in warning_records + ) + assert any("60" in r.message for r in warning_records) + + +async def test_subscribe_clean_return_resets_error_logging( + hass: HomeAssistant, + mock_traccar_api_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """A clean return re-arms error logging for the next failure streak. + + The should-log flag must reset alongside the failure counter - otherwise + a failure streak starting right after a clean return would be silently + throttled instead of logging its first error. + """ + calls = 0 + + async def _fail_then_clean_return_then_fail(_callback: object) -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise TraccarException("First failure") + if calls == 2: + return # Clean return - should re-arm error logging. + if calls == 3: + raise TraccarException("Second failure, after clean return") + raise asyncio.CancelledError + + mock_traccar_api_client.subscribe = AsyncMock( + side_effect=_fail_then_clean_return_then_fail + ) + + with ( + patch( + "homeassistant.components.traccar_server.coordinator._SUBSCRIPTION_RECONNECT_DELAY", + 0, + ), + caplog.at_level(logging.INFO, logger="homeassistant.components.traccar_server"), + ): + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done(wait_background_tasks=True) + + error_records = [ + r + for r in caplog.records + if r.levelno == logging.ERROR + and r.name == "homeassistant.components.traccar_server" + ] + assert len(error_records) == 2 + assert "First failure" in error_records[0].message + assert "Second failure, after clean return" in error_records[1].message diff --git a/tests/components/twinkly/test_init.py b/tests/components/twinkly/test_init.py index 36ddafe4b8e7cf..5b9c93f65f87ad 100644 --- a/tests/components/twinkly/test_init.py +++ b/tests/components/twinkly/test_init.py @@ -15,7 +15,7 @@ from . import setup_integration from .const import TEST_MAC, TEST_MODEL -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.mark.usefixtures("mock_twinkly_client") @@ -84,3 +84,33 @@ async def test_mac_migration( (DOMAIN, config_entry.unique_id), config_entry.entry_id ).identifiers == {(DOMAIN, TEST_MAC)} assert config_entry.unique_id == TEST_MAC + + +@pytest.mark.usefixtures("mock_twinkly_client") +async def test_request_retried_once_on_timeout( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """A request that times out once is retried, so setup still succeeds.""" + details = await async_load_json_object_fixture(hass, "get_details.json", DOMAIN) + # Only the first call times out; without the retry setup would fail here. + mock_twinkly_client.get_details.side_effect = [TimeoutError, details, details] + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_request_gives_up_after_the_retry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_twinkly_client: AsyncMock, +) -> None: + """A request that keeps timing out still fails, after exactly one retry.""" + mock_twinkly_client.get_details.side_effect = TimeoutError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_twinkly_client.get_details.call_count == 2 diff --git a/tests/components/wiim/test_media_player.py b/tests/components/wiim/test_media_player.py index 3b9ad731fee0af..f74bfe9f04fc87 100644 --- a/tests/components/wiim/test_media_player.py +++ b/tests/components/wiim/test_media_player.py @@ -19,6 +19,7 @@ from wiim.wiim_device import WiimDevice from homeassistant.components.media_player import ( + ATTR_ENTITY_PICTURE_LOCAL, ATTR_GROUP_MEMBERS, ATTR_INPUT_SOURCE, ATTR_MEDIA_ALBUM_NAME, @@ -57,13 +58,20 @@ ) import homeassistant.components.wiim as wiim_component from homeassistant.components.wiim.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + CONF_HOST, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import fire_general_update, fire_transport_update, setup_integration from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device" @@ -1313,3 +1321,68 @@ async def test_join_service_invalid_member_uses_translation( assert exc_info.value.translation_key == "invalid_grouping_entity" assert exc_info.value.translation_placeholders == {"entity_id": invalid_entity_id} mock_wiim_controller.async_join_group.assert_not_awaited() + + +@pytest.mark.usefixtures("mock_wiim_controller") +async def test_media_image_hash_changes_for_same_local_artwork_url( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_wiim_device: MagicMock, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test the media proxy returns new artwork when its URL is reused.""" + await setup_integration(hass, mock_config_entry) + image_url = "https://192.168.1.100/changing-album-art.jpg" + + client = await hass_client() + + mock_wiim_device.current_media = WiimMediaMetadata( + title="First Song", + artist="Artist", + album="Album", + uri="http://example.com/first.flac", + image_url=image_url, + ) + await fire_general_update(hass, mock_wiim_device) + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + assert state.attributes[ATTR_ENTITY_PICTURE] == image_url + first_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL] + + aioclient_mock.get( + image_url, + content=b"first-image", + headers={"Content-Type": "image/jpeg"}, + ) + media_response = await client.get(first_local_image) + assert media_response.status == 200 + first_image = await media_response.read() + assert first_image == b"first-image" + + mock_wiim_device.current_media = WiimMediaMetadata( + title="Second Song", + artist="Artist", + album="Album", + uri="http://example.com/second.flac", + image_url=image_url, + ) + await fire_general_update(hass, mock_wiim_device) + state = hass.states.get(MEDIA_PLAYER_ENTITY_ID) + assert state is not None + + assert state.attributes[ATTR_ENTITY_PICTURE] == image_url + second_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL] + assert second_local_image != first_local_image + + aioclient_mock.clear_requests() + aioclient_mock.get( + image_url, + content=b"second-image", + headers={"Content-Type": "image/jpeg"}, + ) + media_response = await client.get(second_local_image) + assert media_response.status == 200 + second_image = await media_response.read() + assert second_image == b"second-image" + assert second_image != first_image diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index df17f105f482f4..75e582ccd27a39 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -7,6 +7,7 @@ from typing import Any from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant import config_entries, data_entry_flow, setup @@ -1051,6 +1052,124 @@ async def test_oauth_session_refresh_failure_exceptions( assert f"Token request for {TEST_DOMAIN} failed" in caplog.text +@pytest.mark.parametrize( + "entry_state", + [ + pytest.param( + config_entries.ConfigEntryState.SETUP_IN_PROGRESS, id="during_setup" + ), + pytest.param(config_entries.ConfigEntryState.LOADED, id="after_setup"), + ], +) +async def test_oauth_session_reauth_error_starts_reauth( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + entry_state: config_entries.ConfigEntryState, +) -> None: + """Test a token refresh reauth error starts reauthentication.""" + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.BAD_REQUEST, json={}) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + config_entry.mock_state(hass, entry_state) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(config_entry, "async_start_reauth_if_available") as start_reauth, + pytest.raises(OAuth2TokenRequestReauthError), + ): + await session.async_ensure_token_valid() + + start_reauth.assert_called_once_with(hass) + + +async def test_oauth_session_reauth_error_starts_reauth_when_caller_recovers( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test reauth starts even when the caller treats the error as recoverable. + + The token request errors subclass ClientResponseError, so a caller catching + ClientError would otherwise retry a revoked refresh token indefinitely. + """ + aioclient_mock.post(TOKEN_URL, status=HTTPStatus.BAD_REQUEST, json={}) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + config_entry.mock_state(hass, config_entries.ConfigEntryState.SETUP_IN_PROGRESS) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(config_entry, "async_start_reauth_if_available") as start_reauth, + pytest.raises(ClientError), + ): + await session.async_ensure_token_valid() + + start_reauth.assert_called_once_with(hass) + + +@pytest.mark.parametrize( + "status_code", + [ + pytest.param(HTTPStatus.TOO_MANY_REQUESTS, id="transient"), + pytest.param(600, id="generic"), + ], +) +async def test_oauth_session_recoverable_error_does_not_start_reauth( + hass: HomeAssistant, + local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, + aioclient_mock: AiohttpClientMocker, + status_code: int, +) -> None: + """Test a recoverable token refresh error does not start reauthentication.""" + aioclient_mock.post(TOKEN_URL, status=status_code, json={}) + + config_entry = MockConfigEntry( + domain=TEST_DOMAIN, + data={ + "auth_implementation": TEST_DOMAIN, + "token": { + "refresh_token": REFRESH_TOKEN, + "access_token": ACCESS_TOKEN_1, + "expires_at": 0, + }, + }, + ) + config_entry.add_to_hass(hass) + config_entry.mock_state(hass, config_entries.ConfigEntryState.LOADED) + + session = config_entry_oauth2_flow.OAuth2Session(hass, config_entry, local_impl) + with ( + patch.object(config_entry, "async_start_reauth_if_available") as start_reauth, + pytest.raises(OAuth2TokenRequestError), + ): + await session.async_ensure_token_valid() + + start_reauth.assert_not_called() + + async def test_oauth2_without_secret_init( local_impl: config_entry_oauth2_flow.LocalOAuth2Implementation, hass_client_no_auth: ClientSessionGenerator, diff --git a/tests/test_requirements.py b/tests/test_requirements.py index ea6f5b8b4b14da..cd968e05b8ff9b 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -683,8 +683,8 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: "pyserial-asyncio", False, "Detected that custom integration", - "which should be replaced by pyserial-asyncio-fast. This will stop" - " working in Home Assistant 2026.7, please create a bug report at " + "which should be replaced by serialx. This will stop" + " working in Home Assistant 2027.2, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" "label%3A%22integration%3A+test_component%22", ), @@ -692,8 +692,8 @@ async def test_discovery_requirements_dhcp(hass: HomeAssistant) -> None: "pyserial-asyncio>=0.6", True, "Detected that integration", - "which should be replaced by pyserial-asyncio-fast. This will stop" - " working in Home Assistant 2026.7, please create a bug report at " + "which should be replaced by serialx. This will stop" + " working in Home Assistant 2027.2, please create a bug report at " "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue+" "label%3A%22integration%3A+test_component%22", ),