From c08aa744967f47dce94e140920400c2aa1263cfb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:27:37 +0200 Subject: [PATCH 01/28] Cleanup Tuya climate/cover tests (#149157) --- tests/components/tuya/__init__.py | 2 +- tests/components/tuya/test_climate.py | 26 ++++++++++++++++---------- tests/components/tuya/test_cover.py | 7 ++++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1ce7e6c47dde87..d9016d18def084 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -15,8 +15,8 @@ DEVICE_MOCKS = { "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 - Platform.SELECT, Platform.COVER, + Platform.SELECT, ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index d564c027cd13b6..9c0e3c31a26361 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -8,6 +8,10 @@ from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -69,16 +73,17 @@ async def test_fan_mode_windspeed( mock_device: CustomerDevice, ) -> None: """Test fan mode with windspeed.""" + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes["fan_mode"] == 1 await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, ) @@ -104,17 +109,18 @@ async def test_fan_mode_no_valid_code( mock_device.status_range.pop("windspeed", None) mock_device.status.pop("windspeed", None) + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes.get("fan_mode") is None with pytest.raises(ServiceNotSupported): await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, blocking=True, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 3b190e468271d8..29a6d65978f88f 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -83,8 +83,9 @@ async def test_percent_state_on_cover( # 100 is closed and 0 is open for Tuya covers mock_device.status["percent_state"] = 100 - percent_state + entity_id = "cover.kitchen_blinds_curtain" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - cover_state = hass.states.get("cover.kitchen_blinds_curtain") - assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" - assert cover_state.attributes["current_position"] == percent_state + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes["current_position"] == percent_state From 8c964e64db2dcae8af227ffbea656ed1b013290b Mon Sep 17 00:00:00 2001 From: Elmo-S <71403256+Elmo-S@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:39:46 +0300 Subject: [PATCH 02/28] Add support for UV index attribute in template weather entity (#149015) --- homeassistant/components/template/weather.py | 26 +++++++++++++++++++ .../template/snapshots/test_weather.ambr | 1 + tests/components/template/test_weather.py | 8 ++++++ 3 files changed, 35 insertions(+) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 15c6fb4db9e93f..7f79adc220164f 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -90,6 +90,7 @@ CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" +CONF_UV_INDEX_TEMPLATE = "uv_index_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" @@ -122,6 +123,7 @@ vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UV_INDEX_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, @@ -201,6 +203,7 @@ def __init__( self._wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) self._wind_bearing_template = config.get(CONF_WIND_BEARING_TEMPLATE) self._ozone_template = config.get(CONF_OZONE_TEMPLATE) + self._uv_index_template = config.get(CONF_UV_INDEX_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) @@ -228,6 +231,7 @@ def __init__( self._wind_speed = None self._wind_bearing = None self._ozone = None + self._uv_index = None self._visibility = None self._wind_gust_speed = None self._cloud_coverage = None @@ -275,6 +279,11 @@ def ozone(self) -> float | None: """Return the ozone level.""" return self._ozone + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._uv_index + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -369,6 +378,11 @@ def _async_setup_templates(self) -> None: "_ozone", self._ozone_template, ) + if self._uv_index_template: + self.add_template_attribute( + "_uv_index", + self._uv_index_template, + ) if self._visibility_template: self.add_template_attribute( "_visibility", @@ -480,6 +494,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone: float | None last_pressure: float | None last_temperature: float | None + last_uv_index: float | None last_visibility: float | None last_wind_bearing: float | str | None last_wind_gust_speed: float | None @@ -501,6 +516,7 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: last_ozone=restored["last_ozone"], last_pressure=restored["last_pressure"], last_temperature=restored["last_temperature"], + last_uv_index=restored["last_uv_index"], last_visibility=restored["last_visibility"], last_wind_bearing=restored["last_wind_bearing"], last_wind_gust_speed=restored["last_wind_gust_speed"], @@ -553,6 +569,7 @@ def __init__( CONF_FORECAST_TWICE_DAILY_TEMPLATE, CONF_OZONE_TEMPLATE, CONF_PRESSURE_TEMPLATE, + CONF_UV_INDEX_TEMPLATE, CONF_VISIBILITY_TEMPLATE, CONF_WIND_BEARING_TEMPLATE, CONF_WIND_GUST_SPEED_TEMPLATE, @@ -583,6 +600,7 @@ async def async_added_to_hass(self) -> None: self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_UV_INDEX_TEMPLATE] = weather_data.last_uv_index self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing self._rendered[CONF_WIND_GUST_SPEED_TEMPLATE] = ( @@ -630,6 +648,13 @@ def ozone(self) -> float | None: self._rendered.get(CONF_OZONE_TEMPLATE), ) + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_UV_INDEX_TEMPLATE) + ) + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -703,6 +728,7 @@ def extra_restore_state_data(self) -> WeatherExtraStoredData: last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_uv_index=self._rendered.get(CONF_UV_INDEX_TEMPLATE), last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index bdda5b44e94394..215a10a4f40fe7 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -46,6 +46,7 @@ 'last_ozone': None, 'last_pressure': None, 'last_temperature': '15.0', + 'last_uv_index': None, 'last_visibility': None, 'last_wind_bearing': None, 'last_wind_gust_speed': None, diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 443b0aa6e774fd..6e2a2ab2f6b502 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -15,6 +15,7 @@ ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -608,6 +609,7 @@ async def test_forecast_format_error( "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -623,6 +625,7 @@ async def test_forecast_format_error( "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -790,6 +793,7 @@ async def test_trigger_action(hass: HomeAssistant) -> None: "wind_speed_template": "{{ my_variable + 1 }}", "wind_bearing_template": "{{ my_variable + 1 }}", "ozone_template": "{{ my_variable + 1 }}", + "uv_index_template": "{{ my_variable + 1 }}", "visibility_template": "{{ my_variable + 1 }}", "pressure_template": "{{ my_variable + 1 }}", "wind_gust_speed_template": "{{ my_variable + 1 }}", @@ -864,6 +868,7 @@ async def test_trigger_weather_services( assert state.attributes["wind_speed"] == 3.0 assert state.attributes["wind_bearing"] == 3.0 assert state.attributes["ozone"] == 3.0 + assert state.attributes["uv_index"] == 3.0 assert state.attributes["visibility"] == 3.0 assert state.attributes["pressure"] == 3.0 assert state.attributes["wind_gust_speed"] == 3.0 @@ -962,6 +967,7 @@ async def test_restore_weather_save_state( "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -1041,6 +1047,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: "wind_speed_template": "{{ states('sensor.windspeed') }}", "wind_bearing_template": "{{ states('sensor.windbearing') }}", "ozone_template": "{{ states('sensor.ozone') }}", + "uv_index_template": "{{ states('sensor.uv_index') }}", "visibility_template": "{{ states('sensor.visibility') }}", "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", @@ -1063,6 +1070,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.uv_index", ATTR_WEATHER_UV_INDEX, 3.7), ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), From 0dba32dbcd1cc855d48e671530e4ab62daecf80e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:11 +0200 Subject: [PATCH 03/28] Use OptionsFlowWithReload in keenetic_ndms2 (#149173) --- homeassistant/components/keenetic_ndms2/__init__.py | 7 ------- homeassistant/components/keenetic_ndms2/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 7986158ab50a90..358f9600845a24 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -33,8 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> router = KeeneticRouter(hass, entry) await router.async_setup() - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,11 +85,6 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index c6095968c07016..cec4796176ebd2 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -12,7 +12,7 @@ SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -153,7 +153,7 @@ async def async_step_ssdp( return await self.async_step_user() -class KeeneticOptionsFlowHandler(OptionsFlow): +class KeeneticOptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" config_entry: KeeneticConfigEntry From c22f65bd87055dec5c9c2b845032eb4b93d70a90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:24 +0200 Subject: [PATCH 04/28] Use OptionsFlowWithReload in isy994 (#149174) --- homeassistant/components/isy994/__init__.py | 6 ------ homeassistant/components/isy994/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 5d4603cafc00f9..68ca63b6bb5abe 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -171,7 +171,6 @@ def _async_stop_auto_update(event: Event) -> None: _LOGGER.debug("ISY Starting Event Stream and automatic updates") isy.websocket.start() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update) ) @@ -179,11 +178,6 @@ def _async_stop_auto_update(event: Event) -> None: return True -async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 2acebee8599811..4f0217fd0c654f 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -18,7 +18,7 @@ SOURCE_IGNORE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -143,7 +143,7 @@ def __init__(self) -> None: @callback def async_get_options_flow( config_entry: IsyConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -316,7 +316,7 @@ async def async_step_reauth_confirm( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for ISY/IoX.""" async def async_step_init( From 94d077ea4150f65b07337a5ce8de09479c0892a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:38 +0200 Subject: [PATCH 05/28] Use OptionsFlowWithReload in honeywell (#149162) --- homeassistant/components/honeywell/__init__.py | 9 --------- homeassistant/components/honeywell/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 6c4c709184089b..d270ffec72f7e4 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -83,18 +83,9 @@ async def async_setup_entry( config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - return True -async def update_listener( - hass: HomeAssistant, config_entry: HoneywellConfigEntry -) -> None: - """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> bool: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 15199cdda24599..c18bb0296aab6d 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -12,7 +12,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -136,7 +136,7 @@ def async_get_options_flow( return HoneywellOptionsFlowHandler() -class HoneywellOptionsFlowHandler(OptionsFlow): +class HoneywellOptionsFlowHandler(OptionsFlowWithReload): """Config flow options for Honeywell.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: From bf1a660dcbb91b80322a03bbf23320f993a43bed Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:02:50 +0200 Subject: [PATCH 06/28] Bump Lokalise docker image to v2.6.14 (#149031) --- script/translations/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 9ff8aeb2d705ec..18aa27b3e745b3 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.8" +CLI_2_DOCKER_IMAGE = "v2.6.14" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") From 1fba61973dbee174ea0e777d00bf78d907d5ab20 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:03:53 +0200 Subject: [PATCH 07/28] Update pytest-asyncio to 1.1.0 (#149177) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b758a7b517ae5f..fa29e7053e0f27 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -19,7 +19,7 @@ pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.0.0 +pytest-asyncio==1.1.0 pytest-aiohttp==1.1.0 pytest-cov==6.2.1 pytest-freezer==0.4.9 From 67c68dedbad6bd5fd22c63a4a48ba350e90452c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:07:52 +0200 Subject: [PATCH 08/28] Make async_track_state_change/report_event listeners fire in order (#148766) --- homeassistant/helpers/event.py | 2 +- tests/helpers/test_event.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f2dfb7250f714b..39cff22396a6f7 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -402,7 +402,7 @@ def _async_track_state_change_event( _KEYED_TRACK_STATE_REPORT = _KeyedEventTracker( key=_TRACK_STATE_REPORT_DATA, event_type=EVENT_STATE_REPORTED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index c875522b943a67..32cf3edf010709 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4969,11 +4969,9 @@ def on_state_report(event: Event[EventStateReportedData]) -> None: hass.states.async_set(entity_id, state) await hass.async_block_till_done() - # The out-of-order is a result of state change listeners scheduled with - # loop.call_soon, whereas state report listeners are called immediately. assert tracker_called == { - "light.bowl": ["on", "off", "on", "off"], - "light.top": ["on", "off", "on", "off"], + "light.bowl": ["on", "on", "off", "off"], + "light.top": ["on", "on", "off", "off"], } From 75a90ab568ffda4d3fd69684349210648b7b35d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:11:22 +0200 Subject: [PATCH 09/28] Bump actions/ai-inference from 1.1.0 to 1.2.3 (#149159) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index b01a0d68352a42..0facf6fdf77fc8 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 264b8ab9854983..b1ce58c4b4175f 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o-mini system-prompt: | From b59d8b57301ce4e40adf8d11fba5a0f8914b0222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:20:04 +0200 Subject: [PATCH 10/28] Improve statistics sensor tests (#149181) --- tests/components/statistics/test_sensor.py | 36 +++++++++++++--------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 1db4acf3ef83cd..e882909878ae0f 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Sequence from datetime import datetime, timedelta import statistics -from threading import Event +from threading import Event as ThreadingEvent from typing import Any from unittest.mock import patch @@ -42,8 +42,9 @@ UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -1741,7 +1742,7 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) # some synchronisation is needed to prevent that loading from the database finishes too soon # we want this to take long enough to be able to try to add a value BEFORE loading is done state_changes_during_period_called_evt = AsyncioEvent() - state_changes_during_period_stall_evt = Event() + state_changes_during_period_stall_evt = ThreadingEvent() real_state_changes_during_period = history.state_changes_during_period def mock_state_changes_during_period(*args, **kwargs): @@ -1789,25 +1790,25 @@ def mock_state_changes_during_period(*args, **kwargs): @pytest.mark.parametrize("force_update", [True, False]) @pytest.mark.parametrize( - ("values_attributes_and_times", "expected_state"), + ("values_attributes_and_times", "expected_states"), [ ( # Fires last reported events [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires state change events [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires last reported events [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ( # Fires state change events [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ], ) @@ -1815,12 +1816,21 @@ async def test_average_linear_unevenly_timed( hass: HomeAssistant, force_update: bool, values_attributes_and_times: list[tuple[float, dict[str, Any], float]], - expected_state: str, + expected_states: list[str], ) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event( + hass, "sensor.test_sensor_average_linear", _capture_event + ) current_time = dt_util.utcnow() @@ -1856,12 +1866,8 @@ async def test_average_linear_unevenly_timed( await hass.async_block_till_done() - state = hass.states.get("sensor.test_sensor_average_linear") - assert state is not None - assert state.state == expected_state, ( - "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == {expected_state}" - ) + await hass.async_block_till_done() + assert [event.data["new_state"].state for event in events] == expected_states async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None: From 05566e1621da6f38adfd764e37d277ccf36304ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:23:42 +0200 Subject: [PATCH 11/28] Update websockets pin (#149004) --- homeassistant/package_constraints.txt | 8 ++------ script/gen_requirements_all.py | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5f72d1c4c344f..157ee1420fcc4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -150,12 +150,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 005d97175a7144..b45d48aeff473b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,12 +176,8 @@ # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python From be25a7bc70c916f171e7a024b143e6236156b4b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 13:24:15 +0200 Subject: [PATCH 12/28] Use OptionsFlowWithReload in ezviz (#149167) --- homeassistant/components/ezviz/__init__.py | 7 ------- homeassistant/components/ezviz/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index a93954b8a9b9bb..65749871093835 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,8 +94,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. @@ -120,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bo return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - - -async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 622f767443d046..d90f04b403a4cb 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,7 +17,11 @@ from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -386,7 +390,7 @@ async def async_step_reauth_confirm( ) -class EzvizOptionsFlowHandler(OptionsFlow): +class EzvizOptionsFlowHandler(OptionsFlowWithReload): """Handle EZVIZ client options.""" async def async_step_init( From d774de79db8a9c96a9fcf23f4bbf9b1d99b4c21c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:33:04 +0200 Subject: [PATCH 13/28] Update types packages (#149178) --- homeassistant/components/habitica/util.py | 9 +++++++-- requirements_test.txt | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 35e1577ae212e6..4f948b9b4d201a 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from dateutil.rrule import ( DAILY, @@ -56,7 +56,12 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +FREQUENCY_MAP: dict[str, Literal[0, 1, 2, 3]] = { + "daily": DAILY, + "weekly": WEEKLY, + "monthly": MONTHLY, + "yearly": YEARLY, +} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} diff --git a/requirements_test.txt b/requirements_test.txt index fa29e7053e0f27..b0affc56113c03 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,17 +35,17 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250606 +types-aiofiles==24.1.0.20250708 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250411 +types-croniter==6.0.0.20250626 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250516 +types-protobuf==6.30.2.20250703 types-psutil==7.0.0.20250601 types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250516 +types-python-dateutil==2.9.0.20250708 types-python-slugify==8.0.2.20240310 types-pytz==2025.2.0.20250516 types-PyYAML==6.0.12.20250516 From bc0162cf858baf76fb0cf1ea59f2151960903c6d Mon Sep 17 00:00:00 2001 From: Luuk Dobber <1858881+luukdobber@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:45:57 +0200 Subject: [PATCH 14/28] Add select for heating circuit to Tado zones (#147902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Abílio Costa --- homeassistant/components/tado/__init__.py | 1 + homeassistant/components/tado/coordinator.py | 62 +- homeassistant/components/tado/select.py | 108 ++++ homeassistant/components/tado/strings.json | 8 + .../tado/fixtures/heating_circuits.json | 7 + .../tado/fixtures/zone_control.json | 80 +++ .../tado/snapshots/test_diagnostics.ambr | 561 ++++++++++++++++++ tests/components/tado/test_select.py | 91 +++ tests/components/tado/util.py | 12 + 9 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tado/select.py create mode 100644 tests/components/tado/fixtures/heating_circuits.json create mode 100644 tests/components/tado/fixtures/zone_control.json create mode 100644 tests/components/tado/test_select.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 0513d63b893ae8..df33845437f0bf 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -41,6 +41,7 @@ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 09c6ec40208a74..79486ff998b1c6 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -73,6 +73,8 @@ def __init__( "weather": {}, "geofence": {}, "zone": {}, + "zone_control": {}, + "heating_circuits": {}, } @property @@ -99,11 +101,14 @@ async def _async_update_data(self) -> dict[str, dict]: self.home_name = tado_home["name"] devices = await self._async_update_devices() - zones = await self._async_update_zones() + zones, zone_controls = await self._async_update_zones() home = await self._async_update_home() + heating_circuits = await self._async_update_heating_circuits() self.data["device"] = devices self.data["zone"] = zones + self.data["zone_control"] = zone_controls + self.data["heating_circuits"] = heating_circuits self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] @@ -166,7 +171,7 @@ def _update_device_info(self, devices: list[dict[str, Any]]) -> dict[str, dict]: return mapped_devices - async def _async_update_zones(self) -> dict[int, dict]: + async def _async_update_zones(self) -> tuple[dict[int, dict], dict[int, dict]]: """Update the zone data from Tado.""" try: @@ -179,10 +184,12 @@ async def _async_update_zones(self) -> dict[int, dict]: raise UpdateFailed(f"Error updating Tado zones: {err}") from err mapped_zones: dict[int, dict] = {} + mapped_zone_controls: dict[int, dict] = {} for zone in zone_states: mapped_zones[int(zone)] = await self._update_zone(int(zone)) + mapped_zone_controls[int(zone)] = await self._update_zone_control(int(zone)) - return mapped_zones + return mapped_zones, mapped_zone_controls async def _update_zone(self, zone_id: int) -> dict[str, str]: """Update the internal data of a zone.""" @@ -199,6 +206,24 @@ async def _update_zone(self, zone_id: int) -> dict[str, str]: _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) return data + async def _update_zone_control(self, zone_id: int) -> dict[str, Any]: + """Update the internal zone control data of a zone.""" + + _LOGGER.debug("Updating zone control for zone %s", zone_id) + try: + zone_control_data = await self.hass.async_add_executor_job( + self._tado.get_zone_control, zone_id + ) + except RequestException as err: + _LOGGER.error( + "Error updating Tado zone control for zone %s: %s", zone_id, err + ) + raise UpdateFailed( + f"Error updating Tado zone control for zone {zone_id}: {err}" + ) from err + + return zone_control_data + async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" @@ -217,6 +242,23 @@ async def _async_update_home(self) -> dict[str, dict]: return {"weather": weather, "geofence": geofence} + async def _async_update_heating_circuits(self) -> dict[str, dict]: + """Update the heating circuits data from Tado.""" + + try: + heating_circuits = await self.hass.async_add_executor_job( + self._tado.get_heating_circuits + ) + except RequestException as err: + _LOGGER.error("Error updating Tado heating circuits: %s", err) + raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err + + mapped_heating_circuits: dict[str, dict] = {} + for circuit in heating_circuits: + mapped_heating_circuits[circuit["driverShortSerialNo"]] = circuit + + return mapped_heating_circuits + async def get_capabilities(self, zone_id: int | str) -> dict: """Fetch the capabilities from Tado.""" @@ -364,6 +406,20 @@ async def set_child_lock(self, device_id: str, enabled: bool) -> None: except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + async def set_heating_circuit(self, zone_id: int, circuit_id: int | None) -> None: + """Set heating circuit for zone.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_heating_circuit, + zone_id, + circuit_id, + ) + except RequestException as exc: + raise HomeAssistantError( + f"Error setting Tado heating circuit: {exc}" + ) from exc + await self._update_zone_control(zone_id) + class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py new file mode 100644 index 00000000000000..6db765128c2884 --- /dev/null +++ b/homeassistant/components/tado/select.py @@ -0,0 +1,108 @@ +"""Module for Tado select entities.""" + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + +NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado select platform.""" + + tado = entry.runtime_data.coordinator + entities: list[SelectEntity] = [ + TadoHeatingCircuitSelectEntity(tado, zone["name"], zone["id"]) + for zone in tado.zones + if zone["type"] == "HEATING" + ] + + async_add_entities(entities, True) + + +class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity): + """Representation of a Tado heating circuit select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_icon = "mdi:water-boiler" + _attr_translation_key = "heating_circuit" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + ) -> None: + """Initialize the Tado heating circuit select entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit" + + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Update the selected heating circuit.""" + heating_circuit_id = ( + None + if option == NO_HEATING_CIRCUIT_OPTION + else self.coordinator.data["heating_circuits"].get(option, {}).get("number") + ) + await self.coordinator.set_heating_circuit(self.zone_id, heating_circuit_id) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + # Heating circuits list + heating_circuits = self.coordinator.data["heating_circuits"].values() + self._attr_options = [NO_HEATING_CIRCUIT_OPTION] + self._attr_options.extend(hc["driverShortSerialNo"] for hc in heating_circuits) + + # Current heating circuit + zone_control = self.coordinator.data["zone_control"].get(self.zone_id) + if zone_control and "heatingCircuit" in zone_control: + heating_circuit_number = zone_control["heatingCircuit"] + if heating_circuit_number is None: + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + # Find heating circuit by number + heating_circuit = next( + ( + hc + for hc in heating_circuits + if hc.get("number") == heating_circuit_number + ), + None, + ) + + if heating_circuit is None: + _LOGGER.error( + "Heating circuit with number %s not found for zone %s", + heating_circuit_number, + self.zone_name, + ) + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + self._attr_current_option = heating_circuit.get( + "driverShortSerialNo" + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 5d9c4237be8841..ba1c9e95683504 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -59,6 +59,14 @@ } } }, + "select": { + "heating_circuit": { + "name": "Heating circuit", + "state": { + "no_heating_circuit": "No circuit" + } + } + }, "switch": { "child_lock": { "name": "Child lock" diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json new file mode 100644 index 00000000000000..723ceb76f9543f --- /dev/null +++ b/tests/components/tado/fixtures/heating_circuits.json @@ -0,0 +1,7 @@ +[ + { + "number": 1, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890" + } +] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json new file mode 100644 index 00000000000000..584fe9f3c92e50 --- /dev/null +++ b/tests/components/tado/fixtures/zone_control.json @@ -0,0 +1,80 @@ +{ + "type": "HEATING", + "earlyStartEnabled": false, + "heatingCircuit": 1, + "duties": { + "type": "HEATING", + "leader": { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + "drivers": [ + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ], + "uis": [ + { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ] + } +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index eefb818a88c1fb..34d26c222fa6a4 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -62,6 +62,13 @@ 'presence': 'HOME', 'presenceLocked': False, }), + 'heating_circuits': dict({ + 'RU1234567890': dict({ + 'driverSerialNo': 'RU1234567890', + 'driverShortSerialNo': 'RU1234567890', + 'number': 1, + }), + }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46, @@ -110,6 +117,560 @@ 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", }), }), + 'zone_control': dict({ + '1': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '2': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '3': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '4': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '5': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '6': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + }), }), 'mobile_devices': dict({ 'mobile_device': dict({ diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py new file mode 100644 index 00000000000000..e57b7510d1b20b --- /dev/null +++ b/tests/components/tado/test_select.py @@ -0,0 +1,91 @@ +"""The select tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +HEATING_CIRCUIT_SELECT_ENTITY = "select.baseboard_heater_heating_circuit" +NO_HEATING_CIRCUIT = "no_heating_circuit" +HEATING_CIRCUIT_OPTION = "RU1234567890" +ZONE_ID = 1 +HEATING_CIRCUIT_ID = 1 + + +async def test_heating_circuit_select(hass: HomeAssistant) -> None: + """Test creation of heating circuit select entity.""" + + await async_init_integration(hass) + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state is not None + assert state.state == HEATING_CIRCUIT_OPTION + assert NO_HEATING_CIRCUIT in state.attributes["options"] + assert HEATING_CIRCUIT_OPTION in state.attributes["options"] + + +@pytest.mark.parametrize( + ("option", "expected_circuit_id"), + [(HEATING_CIRCUIT_OPTION, HEATING_CIRCUIT_ID), (NO_HEATING_CIRCUIT, None)], +) +async def test_heating_circuit_select_action( + hass: HomeAssistant, option, expected_circuit_id +) -> None: + """Test selecting heating circuit option.""" + + await async_init_integration(hass) + + # Test selecting a specific heating circuit + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_heating_circuit" + ) as mock_set_zone_heating_circuit, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_control" + ) as mock_get_zone_control, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: HEATING_CIRCUIT_SELECT_ENTITY, + ATTR_OPTION: option, + }, + blocking=True, + ) + + mock_set_zone_heating_circuit.assert_called_with(ZONE_ID, expected_circuit_id) + assert mock_get_zone_control.called + + +@pytest.mark.usefixtures("caplog") +async def test_heating_circuit_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test when a heating circuit with a specific number is not found.""" + circuit_not_matching_zone_control = 999 + heating_circuits = [ + { + "number": circuit_not_matching_zone_control, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890", + } + ] + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_heating_circuits", + return_value=heating_circuits, + ): + await async_init_integration(hass) + + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state.state == NO_HEATING_CIRCUIT + + assert "Heating circuit with number 1 not found for zone" in caplog.text diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 8ee7209acb269b..5ef0ab5dbf2b0f 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,8 +20,10 @@ async def async_init_integration( me_fixture = "me.json" weather_fixture = "weather.json" home_fixture = "home.json" + home_heating_circuits_fixture = "heating_circuits.json" home_state_fixture = "home_state.json" zones_fixture = "zones.json" + zone_control_fixture = "zone_control.json" zone_states_fixture = "zone_states.json" # WR1 Device @@ -70,6 +72,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/", text=await async_load_fixture(hass, home_fixture, DOMAIN), ) + m.get( + "https://my.tado.com/api/v2/homes/1/heatingCircuits", + text=await async_load_fixture(hass, home_heating_circuits_fixture, DOMAIN), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), @@ -178,6 +184,12 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), ) + zone_ids = [1, 2, 3, 4, 5, 6] + for zone_id in zone_ids: + m.get( + f"https://my.tado.com/api/v2/homes/1/zones/{zone_id}/control", + text=await async_load_fixture(hass, zone_control_fixture, DOMAIN), + ) m.post( "https://login.tado.com/oauth2/token", text=await async_load_fixture(hass, token_fixture, DOMAIN), From 875219ccb551108feaed31f9725d54dde82665fe Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 21 Jul 2025 14:02:04 +0200 Subject: [PATCH 15/28] Adds support for hide_states options in state selector (#148959) --- homeassistant/helpers/selector.py | 6 ++++-- tests/helpers/test_selector.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9eaedc6f5ef4bc..2429b4b23e8593 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1338,7 +1338,8 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" - entity_id: Required[str] + entity_id: str + hide_states: list[str] @SELECTORS.register("state") @@ -1349,7 +1350,8 @@ class StateSelector(Selector[StateSelectorConfig]): CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { - vol.Required("entity_id"): cv.entity_id, + vol.Optional("entity_id"): cv.entity_id, + vol.Optional("hide_states"): [str], # The attribute to filter on, is currently deliberately not # configurable/exposed. We are considering separating state # selectors into two types: one for state and one for attribute. diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 9e8f1b1531167e..50d9da501c5a80 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -565,6 +565,11 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ("on", "armed"), (None, True, 1), ), + ( + {"hide_states": ["unknown", "unavailable"]}, + (), + (), + ), ], ) def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None: From 2d86fa079e9d462f91453cf5e3008adf2bb26b4f Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Mon, 21 Jul 2025 08:14:33 -0400 Subject: [PATCH 16/28] SleepIQ add core climate for SleepNumber Climate 360 beds (#134718) --- homeassistant/components/sleepiq/const.py | 4 + homeassistant/components/sleepiq/number.py | 54 ++++++++++++- homeassistant/components/sleepiq/select.py | 62 ++++++++++++++- homeassistant/components/sleepiq/strings.json | 11 +++ tests/components/sleepiq/conftest.py | 17 ++++ tests/components/sleepiq/test_number.py | 39 ++++++++++ tests/components/sleepiq/test_select.py | 77 ++++++++++++++++++- 7 files changed, 261 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4243684cd52f59..7a9415bac20f13 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -4,6 +4,8 @@ DOMAIN = "sleepiq" ACTUATOR = "actuator" +CORE_CLIMATE_TIMER = "core_climate_timer" +CORE_CLIMATE = "core_climate" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -15,6 +17,8 @@ FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", + CORE_CLIMATE_TIMER: "Core Climate Timer", + CORE_CLIMATE: "Core Climate", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 53d6c366e46a69..ffbcbe7a970383 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -7,20 +7,28 @@ from typing import Any, cast from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQSleeper, ) -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, + CORE_CLIMATE_TIMER, DOMAIN, ENTITY_TYPES, FIRMNESS, @@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" +async def _async_set_core_climate_time( + core_climate: SleepIQCoreClimate, time: int +) -> None: + temperature = CoreTemps(core_climate.temperature) + if temperature != CoreTemps.OFF: + await core_climate.turn_on(temperature, time) + + core_climate.timer = time + + +def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str: + sleeper = sleeper_for_side(bed, core_climate.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}" + + +def _get_core_climate_unique_id( + bed: SleepIQBed, core_climate: SleepIQCoreClimate +) -> str: + return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -132,6 +161,20 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, ), + CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( + key=CORE_CLIMATE_TIMER, + native_min_value=0, + native_max_value=600, + native_step=30, + name=ENTITY_TYPES[CORE_CLIMATE_TIMER], + icon="mdi:timer", + value_fn=lambda core_climate: core_climate.timer, + set_value_fn=_async_set_core_climate_time, + get_name_fn=_get_core_climate_name, + get_unique_id_fn=_get_core_climate_unique_id, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + ), } @@ -172,6 +215,15 @@ async def async_setup_entry( ) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQNumberEntity( + data.data_coordinator, + bed, + core_climate, + NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER], + ) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 7d059ba6b59f19..d4bc9fda3a4358 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, Side, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQPreset, ) @@ -15,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FOOT_WARMER +from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side @@ -37,6 +39,10 @@ async def async_setup_entry( SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) @@ -115,3 +121,57 @@ async def async_select_option(self, option: str) -> None: self._attr_current_option = option await self.coordinator.async_request_refresh() self.async_write_ha_state() + + +class SleepIQCoreTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ core climate temperature select entity.""" + + # Maps to translate between asyncsleepiq and HA's naming preference + SLEEPIQ_TO_HA_CORE_TEMP_MAP = { + CoreTemps.OFF: "off", + CoreTemps.HEATING_PUSH_LOW: "heating_low", + CoreTemps.HEATING_PUSH_MED: "heating_medium", + CoreTemps.HEATING_PUSH_HIGH: "heating_high", + CoreTemps.COOLING_PULL_LOW: "cooling_low", + CoreTemps.COOLING_PULL_MED: "cooling_medium", + CoreTemps.COOLING_PULL_HIGH: "cooling_high", + } + HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()} + + _attr_icon = "mdi:heat-wave" + _attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values()) + _attr_translation_key = "core_temps" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + core_climate: SleepIQCoreClimate, + ) -> None: + """Initialize the select entity.""" + self.core_climate = core_climate + sleeper = sleeper_for_side(bed, core_climate.side) + super().__init__(coordinator, bed, sleeper, CORE_CLIMATE) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + sleepiq_option = CoreTemps(self.core_climate.temperature) + self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option] + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] + timer = self.core_climate.timer or 240 + + if temperature == CoreTemps.OFF: + await self.core_climate.turn_off() + else: + await self.core_climate.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 634202d6da8616..58a35ea914ba5a 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -33,6 +33,17 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "core_temps": { + "state": { + "off": "[%key:common::state::off%]", + "heating_low": "Heating low", + "heating_medium": "Heating medium", + "heating_high": "Heating high", + "cooling_low": "Cooling low", + "cooling_medium": "Cooling medium", + "cooling_high": "Cooling high" + } } } } diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index a9456bd3cc672b..f52f489aec3876 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -7,10 +7,12 @@ from asyncsleepiq import ( BED_PRESETS, + CoreTemps, FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, @@ -29,6 +31,7 @@ BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +CORE_CLIMATE_TIME = 240 SLEEPER_L_ID = "98765" SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" @@ -91,6 +94,7 @@ def mock_bed() -> MagicMock: bed.foundation.lights = [light_1, light_2] bed.foundation.foot_warmers = [] + bed.foundation.core_climates = [] return bed @@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation( preset.options = BED_PRESETS mock_bed.foundation.foot_warmers = [] + mock_bed.foundation.core_climates = [] yield client @@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: foot_warmer_r.timer = FOOT_WARM_TIME foot_warmer_r.temperature = FootWarmingTemps.OFF + core_climate_l = create_autospec(SleepIQCoreClimate) + core_climate_r = create_autospec(SleepIQCoreClimate) + mock_bed.foundation.core_climates = [core_climate_l, core_climate_r] + + core_climate_l.side = Side.LEFT + core_climate_l.timer = CORE_CLIMATE_TIME + core_climate_l.temperature = CoreTemps.COOLING_PULL_MED + + core_climate_r.side = Side.RIGHT + core_climate_r.timer = CORE_CLIMATE_TIME + core_climate_r.temperature = CoreTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f0739aabc9dda2..dd45cdc240009e 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -198,3 +198,42 @@ async def test_foot_warmer_timer( await hass.async_block_till_done() assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 + + +async def test_core_climate_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: + """Test the SleepIQ core climate number values for a bed with two sides.""" + entry = await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert state.state == "240.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 600 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_core_climate_timer" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer", + ATTR_VALUE: 420, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index bbfb612e9cb659..17d57eba7d3472 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from asyncsleepiq import FootWarmingTemps +from asyncsleepiq import CoreTemps, FootWarmingTemps from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -21,6 +21,7 @@ BED_ID, BED_NAME, BED_NAME_LOWER, + CORE_CLIMATE_TIME, FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, @@ -204,3 +205,77 @@ async def test_foot_warmer( mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ 1 ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) + + +async def test_core_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test the SleepIQ select entity for core climate.""" + entry = await setup_platform(hass, SELECT_DOMAIN) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert state.state == "cooling_medium" + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert state.state == CoreTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate", + ATTR_OPTION: "heating_high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME) From 1315095b4a3aa4cf268834a9d763b0a145e7e0fc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 21 Jul 2025 14:16:03 +0200 Subject: [PATCH 17/28] Make spelling of "devolo Home Network" consistent (#149165) --- homeassistant/components/devolo_home_network/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 50177a9b13bade..24bf06ac59cf77 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network app on the device dashboard.", "password": "Password you protected the device with." } }, @@ -22,8 +22,8 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device", + "description": "Do you want to add the devolo Home Network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo Home Network device", "data": { "password": "[%key:common::config_flow::data::password%]" }, From 6b489e0ab6897176fb60c8da444a3c46e37112ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:34:12 +0200 Subject: [PATCH 18/28] Bump sigstore/cosign-installer from 3.9.1 to 3.9.2 (#148985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5ac2e47789b830..820097517634a3 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.1 + uses: sigstore/cosign-installer@v3.9.2 with: cosign-release: "v2.2.3" From 64f190749a5ecccee7ad57d5a0074ff467caca55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 21 Jul 2025 14:39:42 +0200 Subject: [PATCH 19/28] Add Demo Vacuum in entity name (#148629) --- homeassistant/components/demo/vacuum.py | 10 +++++----- tests/components/demo/test_vacuum.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11bf3e3118ba23..ba00bcaedb9db3 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -48,11 +48,11 @@ ) FAN_SPEEDS = ["min", "medium", "high", "max"] -DEMO_VACUUM_COMPLETE = "0_Ground_floor" -DEMO_VACUUM_MOST = "1_First_floor" -DEMO_VACUUM_BASIC = "2_Second_floor" -DEMO_VACUUM_MINIMAL = "3_Third_floor" -DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" +DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" +DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" +DEMO_VACUUM_MINIMAL = "Demo vacuum 3 third floor" +DEMO_VACUUM_NONE = "Demo vacuum 4 fourth floor" async def async_setup_entry( diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 3a627efd3f1bac..a497bd964ec660 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -37,11 +37,15 @@ from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".replace(" ", "_").lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".replace(" ", "_").lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".replace(" ", "_").lower() @pytest.fixture From af0480f2a4b808a3c2a5878a5f92052fee5521d8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:33 +0200 Subject: [PATCH 20/28] Use OptionsFlowWithReload in slide_local (#149168) --- homeassistant/components/slide_local/__init__.py | 7 ------- homeassistant/components/slide_local/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 4690fe8016c4dd..7d2027a985aca2 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -21,16 +21,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 96aac1a135c4ac..7593d502becf64 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,7 +14,11 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -232,7 +236,7 @@ async def async_step_zeroconf_confirm( ) -class SlideOptionsFlowHandler(OptionsFlow): +class SlideOptionsFlowHandler(OptionsFlowWithReload): """Handle a options flow for slide_local.""" async def async_step_init( From 54fa4d635bf7560240a0d02b4917cf6bc2d5c752 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:48 +0200 Subject: [PATCH 21/28] Use OptionsFlowWithReload in sonarr (#149166) --- homeassistant/components/sonarr/__init__.py | 6 ------ homeassistant/components/sonarr/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 960227ff0da962..1c786356486f83 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { "upcoming": CalendarDataUpdateCoordinator( hass, entry, host_configuration, sonarr @@ -126,8 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index e1cedba10e756c..278d3fbd7bb588 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -17,7 +17,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -152,7 +152,7 @@ def _get_user_data_schema(self) -> dict[vol.Marker, type]: return data_schema -class SonarrOptionsFlowHandler(OptionsFlow): +class SonarrOptionsFlowHandler(OptionsFlowWithReload): """Handle Sonarr client options.""" async def async_step_init( From 671523feb3493aa575fe78f30710864257138f27 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:52:14 +0200 Subject: [PATCH 22/28] Use OptionsFlowWithReload in hyperion (#149163) --- homeassistant/components/hyperion/__init__.py | 6 ------ homeassistant/components/hyperion/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 0f49bacd1efbde..60a53193acc828 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -266,16 +266,10 @@ async def async_instances_to_clients_raw(instances: list[dict[str, Any]]) -> Non assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 72e76ef8667cb7..1ef53ad295129c 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -17,7 +17,7 @@ ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_BASE, @@ -431,7 +431,7 @@ def async_get_options_flow( return HyperionOptionsFlow() -class HyperionOptionsFlow(OptionsFlow): +class HyperionOptionsFlow(OptionsFlowWithReload): """Hyperion options flow.""" def _create_client(self) -> client.HyperionClient: From 2476e7e47c70e2c8cd5138d20dd88d1f208bfcd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Jul 2025 15:27:29 +0200 Subject: [PATCH 23/28] Revert setting a user to download translations (#149190) --- script/translations/download.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/translations/download.py b/script/translations/download.py index 6a0d6ba824c064..0c9504f44cd70c 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import os from pathlib import Path import re import subprocess @@ -28,8 +27,6 @@ def run_download_docker(): "-v", f"{DOWNLOAD_DIR}:/opt/dest/locale", "--rm", - "--user", - f"{os.getuid()}:{os.getgid()}", f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}", # Lokalise command "lokalise2", From 102ef257a07461bae22d7831188198cd69db17ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 21 Jul 2025 14:35:35 +0100 Subject: [PATCH 24/28] Bump hass-nabucasa from 0.107.1 to 0.108.0 (#149189) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 642bece1b8e4dc..72748efff6e87a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -13,6 +13,6 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["acme", "hass_nabucasa", "snitun"], - "requirements": ["hass-nabucasa==0.107.1"], + "requirements": ["hass-nabucasa==0.108.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 157ee1420fcc4c..aa0e1768d524c1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==4.0.1 -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.3 diff --git a/pyproject.toml b/pyproject.toml index 6c732066e414e4..b1b43c80cd2d8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ "fnv-hash-fast==1.5.0", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.107.1", + "hass-nabucasa==0.108.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", diff --git a/requirements.txt b/requirements.txt index ed9c100fd3a4ff..e4065bed83ec3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 httpx==0.28.1 home-assistant-bluetooth==1.13.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b7e3fd074b6c14..ccae2a8f8daa37 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30ad1b2e5fe9d8..b401c61739d864 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From f3db3ba3c8903ace7d697ffe3d7f8213ec43fce9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 09:36:12 -0400 Subject: [PATCH 25/28] Bump pyschlage to 2025.7.3 (#149184) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index c5b91cefd2e7ee..b71afe01e5609a 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.7.2"] + "requirements": ["pyschlage==2025.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccae2a8f8daa37..60e64a1ad27d44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b401c61739d864..20c826b73e9724 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 From 80b96b0007afeaad0b7687c8eb823017e43f15f7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 15:40:30 +0200 Subject: [PATCH 26/28] Use OptionsFlowWithReload in roku (#149172) --- homeassistant/components/roku/__init__.py | 7 ------- homeassistant/components/roku/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index be0b20c97fb1d3..46149264e55513 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -25,16 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 47bc86802d284f..b28648589c9d0f 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -13,7 +13,7 @@ SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -202,7 +202,7 @@ def async_get_options_flow( return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlow): +class RokuOptionsFlowHandler(OptionsFlowWithReload): """Handle Roku options.""" async def async_step_init( From 40252763d702aa710a3249f95947a1572374bdf8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:31:28 +0200 Subject: [PATCH 27/28] Switch to a new library in Onkyo (#148613) --- homeassistant/components/onkyo/__init__.py | 26 +- homeassistant/components/onkyo/config_flow.py | 49 +- homeassistant/components/onkyo/const.py | 234 +------- homeassistant/components/onkyo/manifest.json | 5 +- .../components/onkyo/media_player.py | 502 +++++++----------- .../components/onkyo/quality_scale.yaml | 5 +- homeassistant/components/onkyo/receiver.py | 214 ++++---- homeassistant/components/onkyo/services.py | 18 +- homeassistant/components/onkyo/util.py | 8 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/onkyo/__init__.py | 137 ++--- tests/components/onkyo/conftest.py | 197 +++++-- .../onkyo/snapshots/test_media_player.ambr | 203 +++++++ tests/components/onkyo/test_config_flow.py | 293 +++++----- tests/components/onkyo/test_init.py | 88 ++- tests/components/onkyo/test_media_player.py | 230 ++++++++ 17 files changed, 1236 insertions(+), 985 deletions(-) create mode 100644 homeassistant/components/onkyo/util.py create mode 100644 tests/components/onkyo/snapshots/test_media_player.ambr create mode 100644 tests/components/onkyo/test_media_player.py diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index d0f93012eb78e8..a4d1ec8f17514d 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -17,7 +17,7 @@ InputSource, ListeningMode, ) -from .receiver import Receiver, async_interview +from .receiver import ReceiverManager, async_interview from .services import DATA_MP_ENTITIES, async_setup_services _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ class OnkyoData: """Config Entry data.""" - receiver: Receiver + manager: ReceiverManager sources: dict[InputSource, str] sound_modes: dict[ListeningMode, str] @@ -50,11 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo host = entry.data[CONF_HOST] - info = await async_interview(host) + try: + info = await async_interview(host) + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc if info is None: raise ConfigEntryNotReady(f"Unable to connect to: {host}") - receiver = await Receiver.async_create(info) + manager = ReceiverManager(hass, entry, info) sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} @@ -62,11 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} - entry.runtime_data = OnkyoData(receiver, sources, sound_modes) + entry.runtime_data = OnkyoData(manager, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await receiver.conn.connect() + if error := await manager.start(): + try: + await error + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc return True @@ -75,9 +82,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo """Unload Onkyo config entry.""" del hass.data[DATA_MP_ENTITIES][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + entry.runtime_data.manager.start_unloading() - receiver = entry.runtime_data.receiver - receiver.conn.close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 2b8f9981e4a42e..75b0f92043dd6e 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,12 +4,12 @@ import logging from typing import Any +from aioonkyo import ReceiverInfo import voluptuous as vol from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -29,6 +29,7 @@ ) from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import OnkyoConfigEntry from .const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -41,19 +42,20 @@ InputSource, ListeningMode, ) -from .receiver import ReceiverInfo, async_discover, async_interview +from .receiver import async_discover, async_interview +from .util import get_meaning _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_DEFAULT: dict[str, str] = {} -LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_DEFAULT: list[InputSource] = [] +LISTENING_MODES_DEFAULT: list[ListeningMode] = [] INPUT_SOURCES_ALL_MEANINGS = { - input_source.value_meaning: input_source for input_source in InputSource + get_meaning(input_source): input_source for input_source in InputSource } LISTENING_MODES_ALL_MEANINGS = { - listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode + get_meaning(listening_mode): listening_mode for listening_mode in ListeningMode } STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( @@ -91,6 +93,7 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + _LOGGER.debug("Config flow start user") return self.async_show_menu( step_id="user", menu_options=["manual", "eiscp_discovery"] ) @@ -103,10 +106,10 @@ async def async_step_manual( if user_input is not None: host = user_input[CONF_HOST] - _LOGGER.debug("Config flow start manual: %s", host) + _LOGGER.debug("Config flow manual: %s", host) try: info = await async_interview(host) - except Exception: + except OSError: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -156,8 +159,8 @@ async def async_step_eiscp_discovery( _LOGGER.debug("Config flow start eiscp discovery") try: - infos = await async_discover() - except Exception: + infos = list(await async_discover(self.hass)) + except OSError: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -303,8 +306,14 @@ async def async_step_configure_receiver( if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_INPUT_SOURCES: [ + get_meaning(input_source) + for input_source in INPUT_SOURCES_DEFAULT + ], + OPTION_LISTENING_MODES: [ + get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + ], } else: entry_options = reconfigure_entry.options @@ -325,11 +334,12 @@ async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the receiver.""" + _LOGGER.debug("Config flow start reconfigure") return await self.async_step_manual() @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: + def async_get_options_flow(config_entry: OnkyoConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -372,7 +382,10 @@ async def async_step_init( entry_options: Mapping[str, Any] = self.config_entry.options entry_options = { - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_LISTENING_MODES: { + listening_mode.value: get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + }, **entry_options, } @@ -416,11 +429,11 @@ async def async_step_init( suggested_values = { OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning + get_meaning(InputSource(input_source)) for input_source in entry_options[OPTION_INPUT_SOURCES] ], OPTION_LISTENING_MODES: [ - ListeningMode(listening_mode).value_meaning + get_meaning(ListeningMode(listening_mode)) for listening_mode in entry_options[OPTION_LISTENING_MODES] ], } @@ -463,13 +476,13 @@ async def async_step_names( input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): input_sources_schema_dict[ - vol.Required(input_source.value_meaning, default=input_source_name) + vol.Required(get_meaning(input_source), default=input_source_name) ] = TextSelector() listening_modes_schema_dict: dict[Any, Selector] = {} for listening_mode, listening_mode_name in self._listening_modes.items(): listening_modes_schema_dict[ - vol.Required(listening_mode.value_meaning, default=listening_mode_name) + vol.Required(get_meaning(listening_mode), default=listening_mode_name) ] = TextSelector() return self.async_show_form( diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index 851d80c510044c..4f5be4238b42ad 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -1,10 +1,9 @@ """Constants for the Onkyo integration.""" -from enum import Enum import typing -from typing import Literal, Self +from typing import Literal -import pyeiscp +from aioonkyo import HDMIOutputParam, InputSourceParam, ListeningModeParam, Zone DOMAIN = "onkyo" @@ -21,214 +20,37 @@ OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 - -class EnumWithMeaning(Enum): - """Enum with meaning.""" - - value_meaning: str - - def __new__(cls, value: str) -> Self: - """Create enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = cls._get_meanings()[value] - - return obj - - @staticmethod - def _get_meanings() -> dict[str, str]: - raise NotImplementedError - - OPTION_INPUT_SOURCES = "input_sources" OPTION_LISTENING_MODES = "listening_modes" -_INPUT_SOURCE_MEANINGS = { - "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", - "01": "VIDEO2 ··· CBL/SAT", - "02": "VIDEO3 ··· GAME/TV ··· GAME", - "03": "VIDEO4 ··· AUX", - "04": "VIDEO5 ··· AUX2 ··· GAME2", - "05": "VIDEO6 ··· PC", - "06": "VIDEO7", - "07": "HIDDEN1 ··· EXTRA1", - "08": "HIDDEN2 ··· EXTRA2", - "09": "HIDDEN3 ··· EXTRA3", - "10": "DVD ··· BD/DVD", - "11": "STRM BOX", - "12": "TV", - "20": "TAPE ··· TV/TAPE", - "21": "TAPE2", - "22": "PHONO", - "23": "CD ··· TV/CD", - "24": "FM", - "25": "AM", - "26": "TUNER", - "27": "MUSIC SERVER ··· P4S ··· DLNA", - "28": "INTERNET RADIO ··· IRADIO FAVORITE", - "29": "USB ··· USB(FRONT)", - "2A": "USB(REAR)", - "2B": "NETWORK ··· NET", - "2D": "AIRPLAY", - "2E": "BLUETOOTH", - "2F": "USB DAC IN", - "30": "MULTI CH", - "31": "XM", - "32": "SIRIUS", - "33": "DAB", - "40": "UNIVERSAL PORT", - "41": "LINE", - "42": "LINE2", - "44": "OPTICAL", - "45": "COAXIAL", - "55": "HDMI 5", - "56": "HDMI 6", - "57": "HDMI 7", - "80": "MAIN SOURCE", -} - - -class InputSource(EnumWithMeaning): - """Receiver input source.""" +InputSource = InputSourceParam +ListeningMode = ListeningModeParam +HDMIOutput = HDMIOutputParam - DVR = "00" - CBL = "01" - GAME = "02" - AUX = "03" - GAME2 = "04" - PC = "05" - VIDEO7 = "06" - EXTRA1 = "07" - EXTRA2 = "08" - EXTRA3 = "09" - DVD = "10" - STRM_BOX = "11" - TV = "12" - TAPE = "20" - TAPE2 = "21" - PHONO = "22" - CD = "23" - FM = "24" - AM = "25" - TUNER = "26" - MUSIC_SERVER = "27" - INTERNET_RADIO = "28" - USB = "29" - USB_REAR = "2A" - NETWORK = "2B" - AIRPLAY = "2D" - BLUETOOTH = "2E" - USB_DAC_IN = "2F" - MULTI_CH = "30" - XM = "31" - SIRIUS = "32" - DAB = "33" - UNIVERSAL_PORT = "40" - LINE = "41" - LINE2 = "42" - OPTICAL = "44" - COAXIAL = "45" - HDMI_5 = "55" - HDMI_6 = "56" - HDMI_7 = "57" - MAIN_SOURCE = "80" - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _INPUT_SOURCE_MEANINGS - - -_LISTENING_MODE_MEANINGS = { - "00": "STEREO", - "01": "DIRECT", - "02": "SURROUND", - "03": "FILM ··· GAME RPG ··· ADVANCED GAME", - "04": "THX", - "05": "ACTION ··· GAME ACTION", - "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", - "07": "MONO MOVIE", - "08": "ORCHESTRA ··· CLASSICAL", - "09": "UNPLUGGED", - "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", - "0B": "TV LOGIC ··· DRAMA", - "0C": "ALL CH STEREO ··· EXTENDED STEREO", - "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", - "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", - "0F": "MONO", - "11": "PURE AUDIO ··· PURE DIRECT", - "12": "MULTIPLEX", - "13": "FULL MONO ··· MONO MUSIC", - "14": "DOLBY VIRTUAL/SURROUND ENHANCER", - "15": "DTS SURROUND SENSATION", - "16": "AUDYSSEY DSX", - "17": "DTS VIRTUAL:X", - "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", - "23": "STAGE (JAPAN GENRE CONTROL)", - "25": "ACTION (JAPAN GENRE CONTROL)", - "26": "MUSIC (JAPAN GENRE CONTROL)", - "2E": "SPORTS (JAPAN GENRE CONTROL)", - "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", - "41": "DOLBY EX/DTS ES", - "42": "THX CINEMA", - "43": "THX SURROUND EX", - "44": "THX MUSIC", - "45": "THX GAMES", - "50": "THX U(2)/S(2)/I/S CINEMA", - "51": "THX U(2)/S(2)/I/S MUSIC", - "52": "THX U(2)/S(2)/I/S GAMES", - "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", - "81": "PLII/PLIIx MUSIC", - "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", - "83": "NEO:6/NEO:X MUSIC", - "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", - "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", - "86": "PLII/PLIIx GAME", - "87": "NEURAL SURR", - "88": "NEURAL THX/NEURAL SURROUND", - "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", - "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", - "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", - "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", - "8D": "NEURAL THX CINEMA", - "8E": "NEURAL THX MUSIC", - "8F": "NEURAL THX GAMES", - "90": "PLIIz HEIGHT", - "91": "NEO:6 CINEMA DTS SURROUND SENSATION", - "92": "NEO:6 MUSIC DTS SURROUND SENSATION", - "93": "NEURAL DIGITAL MUSIC", - "94": "PLIIz HEIGHT + THX CINEMA", - "95": "PLIIz HEIGHT + THX MUSIC", - "96": "PLIIz HEIGHT + THX GAMES", - "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", - "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", - "99": "PLIIz HEIGHT + THX U2/S2 GAMES", - "9A": "NEO:X GAME", - "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", - "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", - "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", - "A3": "NEO:6 CINEMA + AUDYSSEY DSX", - "A4": "NEO:6 MUSIC + AUDYSSEY DSX", - "A5": "NEURAL SURROUND + AUDYSSEY DSX", - "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", - "A7": "DOLBY EX + AUDYSSEY DSX", - "FF": "AUTO SURROUND", +ZONES = { + Zone.MAIN: "Main", + Zone.ZONE2: "Zone 2", + Zone.ZONE3: "Zone 3", + Zone.ZONE4: "Zone 4", } -class ListeningMode(EnumWithMeaning): - """Receiver listening mode.""" - - _ignore_ = "ListeningMode _k _v _meaning" - - ListeningMode = vars() - for _k in _LISTENING_MODE_MEANINGS: - ListeningMode["I" + _k] = _k - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _LISTENING_MODE_MEANINGS - - -ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} +LEGACY_HDMI_OUTPUT_MAPPING = { + HDMIOutput.ANALOG: "no,analog", + HDMIOutput.MAIN: "yes,out", + HDMIOutput.SUB: "out-sub,sub,hdbaset", + HDMIOutput.BOTH: "both,sub", + HDMIOutput.BOTH_MAIN: "both", + HDMIOutput.BOTH_SUB: "both", +} -PYEISCP_COMMANDS = pyeiscp.commands.COMMANDS +LEGACY_REV_HDMI_OUTPUT_MAPPING = { + "analog": HDMIOutput.ANALOG, + "both": HDMIOutput.BOTH_SUB, + "hdbaset": HDMIOutput.SUB, + "no": HDMIOutput.ANALOG, + "out": HDMIOutput.MAIN, + "out-sub": HDMIOutput.SUB, + "sub": HDMIOutput.BOTH, + "yes": HDMIOutput.MAIN, +} diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 6f37fb61b4426f..07834d4cba1bb1 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -3,11 +3,12 @@ "name": "Onkyo", "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", - "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"], + "loggers": ["aioonkyo"], + "requirements": ["aioonkyo==0.2.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index aed7c51af806a9..2965388236d68f 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,12 +1,12 @@ -"""Support for Onkyo Receivers.""" +"""Media player platform.""" from __future__ import annotations import asyncio -from enum import Enum -from functools import cache import logging -from typing import Any, Literal +from typing import Any + +from aioonkyo import Code, Kind, Status, Zone, command, query, status from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -14,23 +14,25 @@ MediaPlayerState, MediaType, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( DOMAIN, + LEGACY_HDMI_OUTPUT_MAPPING, + LEGACY_REV_HDMI_OUTPUT_MAPPING, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, - PYEISCP_COMMANDS, ZONES, InputSource, ListeningMode, VolumeResolution, ) -from .receiver import Receiver +from .receiver import ReceiverManager from .services import DATA_MP_ENTITIES +from .util import get_meaning _LOGGER = logging.getLogger(__name__) @@ -86,64 +88,6 @@ "input_hdr", ] -type LibValue = str | tuple[str, ...] - - -def _get_single_lib_value(value: LibValue) -> str: - if isinstance(value, str): - return value - return value[-1] - - -def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: - result: dict[T, LibValue] = {} - for k, v in cmds["values"].items(): - try: - key = cls(k) - except ValueError: - continue - result[key] = v["name"] - - return result - - -@cache -def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["SLI"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] - case "zone3": - cmds = PYEISCP_COMMANDS["zone3"]["SL3"] - case "zone4": - cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - - return _get_lib_mapping(cmds, InputSource) - - -@cache -def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: - return {value: key for key, value in _input_source_lib_mappings(zone).items()} - - -@cache -def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["LMD"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] - case _: - return {} - - return _get_lib_mapping(cmds, ListeningMode) - - -@cache -def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: - return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} - async def async_setup_entry( hass: HomeAssistant, @@ -153,10 +97,10 @@ async def async_setup_entry( """Set up MediaPlayer for config entry.""" data = entry.runtime_data - receiver = data.receiver + manager = data.manager all_entities = hass.data[DATA_MP_ENTITIES] - entities: dict[str, OnkyoMediaPlayer] = {} + entities: dict[Zone, OnkyoMediaPlayer] = {} all_entities[entry.entry_id] = entities volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] @@ -164,29 +108,33 @@ async def async_setup_entry( sources = data.sources sound_modes = data.sound_modes - def connect_callback(receiver: Receiver) -> None: - if not receiver.first_connect: + async def connect_callback(reconnect: bool) -> None: + if reconnect: for entity in entities.values(): if entity.enabled: - entity.backfill_state() + await entity.backfill_state() + + async def update_callback(message: Status) -> None: + if isinstance(message, status.Raw): + return + + zone = message.zone - def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None: - zone, _, value = message entity = entities.get(zone) if entity is not None: if entity.enabled: entity.process_update(message) - elif zone in ZONES and value != "N/A": - # When we receive the status for a zone, and the value is not "N/A", - # then zone is available on the receiver, so we create the entity for it. + elif not isinstance(message, status.NotAvailable): + # When we receive a valid status for a zone, then that zone is available on the receiver, + # so we create the entity for it. _LOGGER.debug( "Discovered %s on %s (%s)", ZONES[zone], - receiver.model_name, - receiver.host, + manager.info.model_name, + manager.info.host, ) zone_entity = OnkyoMediaPlayer( - receiver, + manager, zone, volume_resolution=volume_resolution, max_volume=max_volume, @@ -196,25 +144,27 @@ def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None: entities[zone] = zone_entity async_add_entities([zone_entity]) - receiver.callbacks.connect.append(connect_callback) - receiver.callbacks.update.append(update_callback) + manager.callbacks.connect.append(connect_callback) + manager.callbacks.update.append(update_callback) class OnkyoMediaPlayer(MediaPlayerEntity): - """Representation of an Onkyo Receiver Media Player (one per each zone).""" + """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False _supports_volume: bool = False - _supports_sound_mode: bool = False + # None means no technical possibility of support + _supports_sound_mode: bool | None = None _supports_audio_info: bool = False _supports_video_info: bool = False - _query_timer: asyncio.TimerHandle | None = None + + _query_task: asyncio.Task | None = None def __init__( self, - receiver: Receiver, - zone: str, + manager: ReceiverManager, + zone: Zone, *, volume_resolution: VolumeResolution, max_volume: float, @@ -222,80 +172,88 @@ def __init__( sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" - self._receiver = receiver - name = receiver.model_name - identifier = receiver.identifier - self._attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}" - self._attr_unique_id = f"{identifier}_{zone}" - + self._manager = manager self._zone = zone + name = manager.info.model_name + identifier = manager.info.identifier + self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}" + self._attr_unique_id = f"{identifier}_{zone.value}" + self._volume_resolution = volume_resolution self._max_volume = max_volume - self._options_sources = sources - self._source_lib_mapping = _input_source_lib_mappings(zone) - self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) + zone_sources = InputSource.for_zone(zone) self._source_mapping = { - key: value - for key, value in sources.items() - if key in self._source_lib_mapping + key: value for key, value in sources.items() if key in zone_sources } self._rev_source_mapping = { value: key for key, value in self._source_mapping.items() } - self._options_sound_modes = sound_modes - self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) - self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + zone_sound_modes = ListeningMode.for_zone(zone) self._sound_mode_mapping = { - key: value - for key, value in sound_modes.items() - if key in self._sound_mode_lib_mapping + key: value for key, value in sound_modes.items() if key in zone_sound_modes } self._rev_sound_mode_mapping = { value: key for key, value in self._sound_mode_mapping.items() } + self._hdmi_output_mapping = LEGACY_HDMI_OUTPUT_MAPPING + self._rev_hdmi_output_mapping = LEGACY_REV_HDMI_OUTPUT_MAPPING + self._attr_source_list = list(self._rev_source_mapping) self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_supported_features = SUPPORTED_FEATURES_BASE - if zone == "main": + if zone == Zone.MAIN: self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._supports_volume = True self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._supports_sound_mode = True + elif Code.get_from_kind_zone(Kind.LISTENING_MODE, zone) is not None: + # To be detected later: + self._supports_sound_mode = False self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" - self.backfill_state() + await self.backfill_state() async def async_will_remove_from_hass(self) -> None: """Cancel the query timer when the entity is removed.""" - if self._query_timer: - self._query_timer.cancel() - self._query_timer = None + if self._query_task: + self._query_task.cancel() + self._query_task = None - @callback - def _update_receiver(self, propname: str, value: Any) -> None: - """Update a property in the receiver.""" - self._receiver.conn.update_property(self._zone, propname, value) + async def backfill_state(self) -> None: + """Get the receiver to send all the info we care about. - @callback - def _query_receiver(self, propname: str) -> None: - """Cause the receiver to send an update about a property.""" - self._receiver.conn.query_property(self._zone, propname) + Usually run only on connect, as we can otherwise rely on the + receiver to keep us informed of changes. + """ + await self._manager.write(query.Power(self._zone)) + await self._manager.write(query.Volume(self._zone)) + await self._manager.write(query.Muting(self._zone)) + await self._manager.write(query.InputSource(self._zone)) + await self._manager.write(query.TunerPreset(self._zone)) + if self._supports_sound_mode is not None: + await self._manager.write(query.ListeningMode(self._zone)) + if self._zone == Zone.MAIN: + await self._manager.write(query.HDMIOutput()) + await self._manager.write(query.AudioInformation()) + await self._manager.write(query.VideoInformation()) async def async_turn_on(self) -> None: """Turn the media player on.""" - self._update_receiver("power", "on") + message = command.Power(self._zone, command.Power.Param.ON) + await self._manager.write(message) async def async_turn_off(self) -> None: """Turn the media player off.""" - self._update_receiver("power", "standby") + message = command.Power(self._zone, command.Power.Param.STANDBY) + await self._manager.write(message) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1. @@ -307,28 +265,30 @@ async def async_set_volume_level(self, volume: float) -> None: scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION - self._update_receiver( - "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) - ) + value = round(volume * (self._max_volume / 100) * self._volume_resolution) + message = command.Volume(self._zone, value) + await self._manager.write(message) async def async_volume_up(self) -> None: """Increase volume by 1 step.""" - self._update_receiver("volume", "level-up") + message = command.Volume(self._zone, command.Volume.Param.UP) + await self._manager.write(message) async def async_volume_down(self) -> None: """Decrease volume by 1 step.""" - self._update_receiver("volume", "level-down") + message = command.Volume(self._zone, command.Volume.Param.DOWN) + await self._manager.write(message) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - self._update_receiver( - "audio-muting" if self._zone == "main" else "muting", - "on" if mute else "off", + message = command.Muting( + self._zone, command.Muting.Param.ON if mute else command.Muting.Param.OFF ) + await self._manager.write(message) async def async_select_source(self, source: str) -> None: """Select input source.""" - if not self.source_list or source not in self.source_list: + if source not in self._rev_source_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_source", @@ -338,15 +298,12 @@ async def async_select_source(self, source: str) -> None: }, ) - source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] - source_lib_single = _get_single_lib_value(source_lib) - self._update_receiver( - "input-selector" if self._zone == "main" else "selector", source_lib_single - ) + message = command.InputSource(self._zone, self._rev_source_mapping[source]) + await self._manager.write(message) async def async_select_sound_mode(self, sound_mode: str) -> None: """Select listening sound mode.""" - if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + if sound_mode not in self._rev_sound_mode_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_sound_mode", @@ -356,197 +313,138 @@ async def async_select_sound_mode(self, sound_mode: str) -> None: }, ) - sound_mode_lib = self._sound_mode_lib_mapping[ - self._rev_sound_mode_mapping[sound_mode] - ] - sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) - self._update_receiver("listening-mode", sound_mode_lib_single) + message = command.ListeningMode( + self._zone, self._rev_sound_mode_mapping[sound_mode] + ) + await self._manager.write(message) async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" - self._update_receiver("hdmi-output-selector", hdmi_output) + message = command.HDMIOutput(self._rev_hdmi_output_mapping[hdmi_output]) + await self._manager.write(message) async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play radio station by preset number.""" - if self.source is not None: - source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: - self._update_receiver("preset", media_id) - - @callback - def backfill_state(self) -> None: - """Get the receiver to send all the info we care about. - - Usually run only on connect, as we can otherwise rely on the - receiver to keep us informed of changes. - """ - self._query_receiver("power") - self._query_receiver("volume") - self._query_receiver("preset") - if self._zone == "main": - self._query_receiver("hdmi-output-selector") - self._query_receiver("audio-muting") - self._query_receiver("input-selector") - self._query_receiver("listening-mode") - self._query_receiver("audio-information") - self._query_receiver("video-information") - else: - self._query_receiver("muting") - self._query_receiver("selector") - - @callback - def process_update(self, update: tuple[str, str, Any]) -> None: - """Store relevant updates so they can be queried later.""" - zone, command, value = update - if zone != self._zone: + if self.source is None: return - if command in ["system-power", "power"]: - if value == "on": - self._attr_state = MediaPlayerState.ON - else: - self._attr_state = MediaPlayerState.OFF - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_PRESET, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) - elif command in ["volume", "master-volume"] and value != "N/A": - if not self._supports_volume: - self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME - self._supports_volume = True - # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) - volume_level: float = value / ( - self._volume_resolution * self._max_volume / 100 - ) - self._attr_volume_level = min(1, volume_level) - elif command in ["muting", "audio-muting"]: - self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"] and value != "N/A": - self._parse_source(value) - self._query_av_info_delayed() - elif command == "hdmi-output-selector": - self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value) - elif command == "preset": - if self.source is not None and self.source.lower() == "radio": - self._attr_extra_state_attributes[ATTR_PRESET] = value - elif ATTR_PRESET in self._attr_extra_state_attributes: - del self._attr_extra_state_attributes[ATTR_PRESET] - elif command == "listening-mode" and value != "N/A": - if not self._supports_sound_mode: - self._attr_supported_features |= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) - self._supports_sound_mode = True - self._parse_sound_mode(value) - self._query_av_info_delayed() - elif command == "audio-information": - self._supports_audio_info = True - self._parse_audio_information(value) - elif command == "video-information": - self._supports_video_info = True - self._parse_video_information(value) - elif command == "fl-display-information": - self._query_av_info_delayed() - - self.async_write_ha_state() - - @callback - def _parse_source(self, source_lib: LibValue) -> None: - source = self._rev_source_lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] + source = self._rev_source_mapping.get(self.source) + if media_type.lower() != "radio" or source not in PLAYABLE_SOURCES: return - source_meaning = source.value_meaning - - if source not in self._options_sources: - _LOGGER.warning( - 'Input source "%s" for entity: %s is not in the list. Check integration options', - source_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) - - self._attr_source = source_meaning - - @callback - def _parse_sound_mode(self, mode_lib: LibValue) -> None: - sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] - if sound_mode in self._sound_mode_mapping: - self._attr_sound_mode = self._sound_mode_mapping[sound_mode] - return + message = command.TunerPreset(self._zone, int(media_id)) + await self._manager.write(message) - sound_mode_meaning = sound_mode.value_meaning + def process_update(self, message: status.Known) -> None: + """Process update.""" + match message: + case status.Power(status.Power.Param.ON): + self._attr_state = MediaPlayerState.ON + case status.Power(status.Power.Param.STANDBY): + self._attr_state = MediaPlayerState.OFF - if sound_mode not in self._options_sound_modes: - _LOGGER.warning( - 'Listening mode "%s" for entity: %s is not in the list. Check integration options', - sound_mode_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) + case status.Volume(volume): + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) + volume_level: float = volume / ( + self._volume_resolution * self._max_volume / 100 + ) + self._attr_volume_level = min(1, volume_level) + + case status.Muting(muting): + self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON) + + case status.InputSource(source): + if source in self._source_mapping: + self._attr_source = self._source_mapping[source] + else: + source_meaning = get_meaning(source) + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning + + self._query_av_info_delayed() + + case status.ListeningMode(sound_mode): + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + else: + sound_mode_meaning = get_meaning(sound_mode) + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + + self._query_av_info_delayed() + + case status.HDMIOutput(hdmi_output): + self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ( + self._hdmi_output_mapping[hdmi_output] + ) + self._query_av_info_delayed() + + case status.TunerPreset(preset): + self._attr_extra_state_attributes[ATTR_PRESET] = preset + + case status.AudioInformation(): + self._supports_audio_info = True + audio_information = {} + for item in AUDIO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + audio_information[item] = item_value + self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = ( + audio_information + ) - self._attr_sound_mode = sound_mode_meaning + case status.VideoInformation(): + self._supports_video_info = True + video_information = {} + for item in VIDEO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + video_information[item] = item_value + self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = ( + video_information + ) - @callback - def _parse_audio_information( - self, audio_information: tuple[str] | Literal["N/A"] - ) -> None: - # If audio information is not available, N/A is returned, - # so only update the audio information, when it is not N/A. - if audio_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - return + case status.FLDisplay(): + self._query_av_info_delayed() - self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = { - name: value - for name, value in zip( - AUDIO_INFORMATION_MAPPING, audio_information, strict=False - ) - if len(value) > 0 - } + case status.NotAvailable(Kind.AUDIO_INFORMATION): + # Not available right now, but still supported + self._supports_audio_info = True - @callback - def _parse_video_information( - self, video_information: tuple[str] | Literal["N/A"] - ) -> None: - # If video information is not available, N/A is returned, - # so only update the video information, when it is not N/A. - if video_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - return + case status.NotAvailable(Kind.VIDEO_INFORMATION): + # Not available right now, but still supported + self._supports_video_info = True - self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = { - name: value - for name, value in zip( - VIDEO_INFORMATION_MAPPING, video_information, strict=False - ) - if len(value) > 0 - } + self.async_write_ha_state() def _query_av_info_delayed(self) -> None: - if self._zone == "main" and not self._query_timer: + if self._zone == Zone.MAIN and not self._query_task: - @callback - def _query_av_info() -> None: + async def _query_av_info() -> None: + await asyncio.sleep(AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME) if self._supports_audio_info: - self._query_receiver("audio-information") + await self._manager.write(query.AudioInformation()) if self._supports_video_info: - self._query_receiver("video-information") - self._query_timer = None + await self._manager.write(query.VideoInformation()) + self._query_task = None - self._query_timer = self.hass.loop.call_later( - AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info - ) + self._query_task = asyncio.create_task(_query_av_info()) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 4b9fbe7c019d70..caf0d33fafc7c5 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -77,7 +77,4 @@ rules: status: exempt comment: | This integration is not making any HTTP requests. - strict-typing: - status: todo - comment: | - The library is not fully typed yet. + strict-typing: done diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index cc6cbbc95fbf12..e4fe8bc663059f 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -3,149 +3,149 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable import contextlib from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING -import pyeiscp +import aioonkyo +from aioonkyo import Instruction, Receiver, ReceiverInfo, Status, connect, query + +from homeassistant.components import network +from homeassistant.core import HomeAssistant from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES +if TYPE_CHECKING: + from . import OnkyoConfigEntry + _LOGGER = logging.getLogger(__name__) @dataclass class Callbacks: - """Onkyo Receiver Callbacks.""" + """Receiver callbacks.""" + + connect: list[Callable[[bool], Awaitable[None]]] = field(default_factory=list) + update: list[Callable[[Status], Awaitable[None]]] = field(default_factory=list) + + def clear(self) -> None: + """Clear all callbacks.""" + self.connect.clear() + self.update.clear() + + +class ReceiverManager: + """Receiver manager.""" + + hass: HomeAssistant + entry: OnkyoConfigEntry + info: ReceiverInfo + receiver: Receiver | None = None + callbacks: Callbacks + + _started: asyncio.Event + + def __init__( + self, hass: HomeAssistant, entry: OnkyoConfigEntry, info: ReceiverInfo + ) -> None: + """Init receiver manager.""" + self.hass = hass + self.entry = entry + self.info = info + self.callbacks = Callbacks() + self._started = asyncio.Event() + + async def start(self) -> Awaitable[None] | None: + """Start the receiver manager run. + + Returns `None`, if everything went fine. + Returns an awaitable with exception set, if something went wrong. + """ + manager_task = self.entry.async_create_background_task( + self.hass, self._run(), "run_connection" + ) + wait_for_started_task = asyncio.create_task(self._started.wait()) + done, _ = await asyncio.wait( + (manager_task, wait_for_started_task), return_when=asyncio.FIRST_COMPLETED + ) + if manager_task in done: + # Something went wrong, so let's return the manager task, + # so that it can be awaited to error out + return manager_task - connect: list[Callable[[Receiver], None]] = field(default_factory=list) - update: list[Callable[[Receiver, tuple[str, str, Any]], None]] = field( - default_factory=list - ) + return None + async def _run(self) -> None: + """Run the connection to the receiver.""" + reconnect = False + while True: + try: + async with connect(self.info, retry=reconnect) as self.receiver: + if not reconnect: + self._started.set() + else: + _LOGGER.info("Reconnected: %s", self.info) -@dataclass -class Receiver: - """Onkyo receiver.""" - - conn: pyeiscp.Connection - model_name: str - identifier: str - host: str - first_connect: bool = True - callbacks: Callbacks = field(default_factory=Callbacks) - - @classmethod - async def async_create(cls, info: ReceiverInfo) -> Receiver: - """Set up Onkyo Receiver.""" - - receiver: Receiver | None = None - - def on_connect(_origin: str) -> None: - assert receiver is not None - receiver.on_connect() - - def on_update(message: tuple[str, str, Any], _origin: str) -> None: - assert receiver is not None - receiver.on_update(message) - - _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) - - connection = await pyeiscp.Connection.create( - host=info.host, - port=info.port, - connect_callback=on_connect, - update_callback=on_update, - auto_connect=False, - ) + await self.on_connect(reconnect=reconnect) - return ( - receiver := cls( - conn=connection, - model_name=info.model_name, - identifier=info.identifier, - host=info.host, - ) - ) + while message := await self.receiver.read(): + await self.on_update(message) + + reconnect = True + + finally: + _LOGGER.info("Disconnected: %s", self.info) - def on_connect(self) -> None: + async def on_connect(self, reconnect: bool) -> None: """Receiver (re)connected.""" - _LOGGER.debug("Receiver (re)connected: %s (%s)", self.model_name, self.host) # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. for zone in ZONES: - self.conn.query_property(zone, "power") + await self.write(query.Power(zone)) for callback in self.callbacks.connect: - callback(self) + await callback(reconnect) - self.first_connect = False - - def on_update(self, message: tuple[str, str, Any]) -> None: + async def on_update(self, message: Status) -> None: """Process new message from the receiver.""" - _LOGGER.debug("Received update callback from %s: %s", self.model_name, message) for callback in self.callbacks.update: - callback(self, message) + await callback(message) + async def write(self, message: Instruction) -> None: + """Write message to the receiver.""" + assert self.receiver is not None + await self.receiver.write(message) -@dataclass -class ReceiverInfo: - """Onkyo receiver information.""" - - host: str - port: int - model_name: str - identifier: str + def start_unloading(self) -> None: + """Start unloading.""" + self.callbacks.clear() async def async_interview(host: str) -> ReceiverInfo | None: - """Interview Onkyo Receiver.""" - _LOGGER.debug("Interviewing receiver: %s", host) - - receiver_info: ReceiverInfo | None = None - - event = asyncio.Event() - - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver interviewed, connection not yet active.""" - nonlocal receiver_info - if receiver_info is None: - info = ReceiverInfo(host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host) - receiver_info = info - event.set() - - timeout = DEVICE_INTERVIEW_TIMEOUT - - await pyeiscp.Connection.discover( - host=host, discovery_callback=_callback, timeout=timeout - ) - + """Interview the receiver.""" + info: ReceiverInfo | None = None with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout) - - return receiver_info - - -async def async_discover() -> Iterable[ReceiverInfo]: - """Discover Onkyo Receivers.""" - _LOGGER.debug("Discovering receivers") + async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): + info = await aioonkyo.interview(host) + return info - receiver_infos: list[ReceiverInfo] = [] - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver discovered, connection not yet active.""" - info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host) - receiver_infos.append(info) +async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: + """Discover receivers.""" + all_infos: dict[str, ReceiverInfo] = {} - timeout = DEVICE_DISCOVERY_TIMEOUT + async def collect_infos(address: str) -> None: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(DEVICE_DISCOVERY_TIMEOUT): + async for info in aioonkyo.discover(address): + all_infos.setdefault(info.identifier, info) - await pyeiscp.Connection.discover(discovery_callback=_callback, timeout=timeout) + broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [collect_infos(str(address)) for address in broadcast_addrs] - await asyncio.sleep(timeout) + await asyncio.gather(*tasks) - return receiver_infos + return all_infos.values() diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index 26a22523a0e38c..cfd246d9af779b 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING +from aioonkyo import Zone import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -12,29 +13,18 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DOMAIN, LEGACY_REV_HDMI_OUTPUT_MAPPING if TYPE_CHECKING: from .media_player import OnkyoMediaPlayer -DATA_MP_ENTITIES: HassKey[dict[str, dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) +DATA_MP_ENTITIES: HassKey[dict[str, dict[Zone, OnkyoMediaPlayer]]] = HassKey(DOMAIN) ATTR_HDMI_OUTPUT = "hdmi_output" -ACCEPTED_VALUES = [ - "no", - "analog", - "yes", - "out", - "out-sub", - "sub", - "hdbaset", - "both", - "up", -] ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), + vol.Required(ATTR_HDMI_OUTPUT): vol.In(LEGACY_REV_HDMI_OUTPUT_MAPPING), } ) SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" diff --git a/homeassistant/components/onkyo/util.py b/homeassistant/components/onkyo/util.py new file mode 100644 index 00000000000000..bd2cc8a4c7b047 --- /dev/null +++ b/homeassistant/components/onkyo/util.py @@ -0,0 +1,8 @@ +"""Utils for Onkyo.""" + +from .const import InputSource, ListeningMode + + +def get_meaning(param: InputSource | ListeningMode) -> str: + """Get param meaning.""" + return " ··· ".join(param.meanings) diff --git a/requirements_all.txt b/requirements_all.txt index 60e64a1ad27d44..513422df9153b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,6 +330,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1956,9 +1959,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20c826b73e9724..4fac0aba573e8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -312,6 +312,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1631,9 +1634,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 689711888d81dd..f8580c2b257c36 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -1,90 +1,71 @@ """Tests for the Onkyo integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +from aioonkyo import ReceiverInfo -from homeassistant.components.onkyo.receiver import Receiver, ReceiverInfo -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry - -def create_receiver_info(id: int) -> ReceiverInfo: - """Create an empty receiver info object for testing.""" - return ReceiverInfo( - host=f"host {id}", - port=id, - model_name=f"type {id}", - identifier=f"id{id}", - ) - - -def create_connection(id: int) -> Mock: - """Create an mock connection object for testing.""" - connection = Mock() - connection.host = f"host {id}" - connection.port = 0 - connection.name = f"type {id}" - connection.identifier = f"id{id}" - return connection - - -def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: - """Create a config entry from receiver info.""" - data = {CONF_HOST: info.host} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } - - return MockConfigEntry( - data=data, - options=options, - title=info.model_name, - domain="onkyo", - unique_id=info.identifier, - ) - - -def create_empty_config_entry() -> MockConfigEntry: - """Create an empty config entry for use in unit tests.""" - data = {CONF_HOST: ""} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } - - return MockConfigEntry( - data=data, - options=options, - title="Unit test Onkyo", - domain="onkyo", - unique_id="onkyo_unique_id", - ) - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, receiver_info: ReceiverInfo -) -> None: - """Fixture for setting up the component.""" - - config_entry.add_to_hass(hass) - - mock_receiver = AsyncMock() - mock_receiver.conn.close = Mock() - mock_receiver.callbacks.connect = Mock() - mock_receiver.callbacks.update = Mock() +RECEIVER_INFO = ReceiverInfo( + host="192.168.0.101", + ip="192.168.0.101", + model_name="TX-NR7100", + identifier="0009B0123456", +) + +RECEIVER_INFO_2 = ReceiverInfo( + host="192.168.0.102", + ip="192.168.0.102", + model_name="TX-RZ50", + identifier="0009B0ABCDEF", +) + + +@contextmanager +def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: + """Mock discovery functions.""" + + async def get_info(host: str) -> ReceiverInfo | None: + """Get receiver info by host.""" + for info in receiver_infos: + if info.host == host: + return info + return None + + def get_infos(host: str) -> MagicMock: + """Get receiver infos from broadcast.""" + discover_mock = MagicMock() + discover_mock.__aiter__.return_value = receiver_infos + return discover_mock + + discover_kwargs = {} + interview_kwargs = {} + if receiver_infos is None: + discover_kwargs["side_effect"] = OSError + interview_kwargs["side_effect"] = OSError + else: + discover_kwargs["new"] = get_infos + interview_kwargs["new"] = get_info with ( patch( - "homeassistant.components.onkyo.async_interview", - return_value=receiver_info, + "homeassistant.components.onkyo.receiver.aioonkyo.discover", + **discover_kwargs, + ), + patch( + "homeassistant.components.onkyo.receiver.aioonkyo.interview", + **interview_kwargs, ), - patch.object(Receiver, "async_create", return_value=mock_receiver), ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + yield + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the component.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/onkyo/conftest.py b/tests/components/onkyo/conftest.py index abbe39dd9669c0..c6459a2b1f293b 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,74 +1,181 @@ -"""Configure tests for the Onkyo integration.""" +"""Common fixtures for the Onkyo tests.""" -from unittest.mock import patch +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status import pytest from homeassistant.components.onkyo.const import DOMAIN +from homeassistant.const import CONF_HOST -from . import create_connection +from . import RECEIVER_INFO, mock_discovery from tests.common import MockConfigEntry -@pytest.fixture(name="config_entry") -def mock_config_entry() -> MockConfigEntry: - """Create Onkyo entry in Home Assistant.""" - return MockConfigEntry( - domain=DOMAIN, - title="Onkyo", - data={}, - ) - - @pytest.fixture(autouse=True) -def patch_timeouts(): - """Patch timeouts to avoid tests waiting.""" - with patch.multiple( - "homeassistant.components.onkyo.receiver", - DEVICE_INTERVIEW_TIMEOUT=0, - DEVICE_DISCOVERY_TIMEOUT=0, +def mock_default_discovery() -> Generator[None]: + """Mock the discovery functions with default info.""" + with ( + patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=1, + DEVICE_DISCOVERY_TIMEOUT=1, + ), + mock_discovery([RECEIVER_INFO]), ): yield @pytest.fixture -async def default_mock_discovery(): - """Mock discovery with a single device.""" +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock integration setup.""" + with patch( + "homeassistant.components.onkyo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(create_connection(1)) +@pytest.fixture +def mock_connect() -> Generator[AsyncMock]: + """Mock an Onkyo connect.""" with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield + "homeassistant.components.onkyo.receiver.connect", + ) as connect: + yield connect.return_value.__aenter__ + + +INITIAL_MESSAGES = [ + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Volume(Code.from_kind_zone(Kind.VOLUME, Zone.ZONE2), None, 50), + status.Muting( + Code.from_kind_zone(Kind.MUTING, Zone.MAIN), None, status.Muting.Param.OFF + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.MAIN), + None, + status.InputSource.Param("24"), + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.ZONE2), + None, + status.InputSource.Param("00"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.MAIN), + None, + status.ListeningMode.Param("01"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.ZONE2), + None, + status.ListeningMode.Param("00"), + ), + status.HDMIOutput( + Code.from_kind_zone(Kind.HDMI_OUTPUT, Zone.MAIN), + None, + status.HDMIOutput.Param.MAIN, + ), + status.TunerPreset(Code.from_kind_zone(Kind.TUNER_PRESET, Zone.MAIN), None, 1), + status.AudioInformation( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + auto_phase_control_phase="Normal", + ), + status.VideoInformation( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + input_color_depth="24bit", + ), + status.FLDisplay(Code.from_kind_zone(Kind.FL_DISPLAY, Zone.MAIN), None, "LALALA"), + status.NotAvailable( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + Kind.AUDIO_INFORMATION, + ), + status.NotAvailable( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + Kind.VIDEO_INFORMATION, + ), + status.Raw(None, None), +] @pytest.fixture -async def stub_mock_discovery(): - """Mock discovery with no devices.""" +def read_queue() -> asyncio.Queue[Status | None]: + """Read messages queue.""" + return asyncio.Queue() - async def mock_discover(host=None, discovery_callback=None, timeout=0): - pass - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield +@pytest.fixture +def writes() -> list[Instruction]: + """Written messages.""" + return [] @pytest.fixture -async def empty_mock_discovery(): - """Mock discovery with an empty connection.""" +def mock_receiver( + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], + writes: list[Instruction], +) -> AsyncMock: + """Mock an Onkyo receiver.""" + receiver_class = AsyncMock(Receiver, auto_spec=True) + receiver = receiver_class.return_value - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(None) + for message in INITIAL_MESSAGES: + read_queue.put_nowait(message) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield + async def read() -> Status: + return await read_queue.get() + + async def write(message: Instruction) -> None: + writes.append(message) + + receiver.read = read + receiver.write = write + + mock_connect.return_value = receiver + + return receiver + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + data = {CONF_HOST: RECEIVER_INFO.host} + options = { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": {"12": "TV", "24": "FM Radio"}, + "listening_modes": {"00": "Stereo", "04": "THX"}, + } + + return MockConfigEntry( + domain=DOMAIN, + title=RECEIVER_INFO.model_name, + unique_id=RECEIVER_INFO.identifier, + data=data, + options=options, + ) diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..1504952a86d50c --- /dev/null +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -0,0 +1,203 @@ +# serializer version: 1 +# name: test_entities[media_player.tx_nr7100-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'audio_information': dict({ + 'auto_phase_control_phase': 'Normal', + }), + 'friendly_name': 'TX-NR7100', + 'is_volume_muted': False, + 'preset': 1, + 'sound_mode': 'DIRECT', + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source': 'FM Radio', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'video_information': dict({ + 'input_color_depth': '24bit', + }), + 'video_out': 'yes,out', + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 2', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 2', + 'sound_mode': 'Stereo', + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source': 'VIDEO1 ··· VCR/DVR ··· STB/DVR', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'volume_level': 0.625, + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 3', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 3', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 92a4a34e8fbdb8..df10e266982627 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,11 +1,9 @@ """Test Onkyo config flow.""" -from unittest.mock import patch - +from aioonkyo import ReceiverInfo import pytest from homeassistant import config_entries -from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -23,17 +21,15 @@ SsdpServiceInfo, ) -from . import ( - create_config_entry_from_info, - create_connection, - create_empty_config_entry, - create_receiver_info, - setup_integration, -) +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry +def _entry_title(receiver_info: ReceiverInfo) -> str: + return f"{receiver_info.model_name} ({receiver_info.host})" + + async def test_user_initial_menu(hass: HomeAssistant) -> None: """Test initial menu.""" init_result = await hass.config_entries.flow.async_init( @@ -46,7 +42,7 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None: assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} -async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: +async def test_manual_valid_host(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -60,14 +56,16 @@ async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> select_result = await hass.config_entries.flow.async_configure( form_result["flow_id"], - user_input={CONF_HOST: "host 1"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" + assert select_result["description_placeholders"]["name"] == _entry_title( + RECEIVER_INFO + ) -async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: +async def test_manual_invalid_host(hass: HomeAssistant) -> None: """Test invalid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -79,18 +77,17 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + with mock_discovery([]): + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "cannot_connect" -async def test_manual_valid_host_unexpected_error( - hass: HomeAssistant, empty_mock_discovery -) -> None: +async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( @@ -103,112 +100,102 @@ async def test_manual_valid_host_unexpected_error( {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + with mock_discovery(None): + host_result = await hass.config_entries.flow.async_configure( + form_result["flow_id"], + user_input={CONF_HOST: "sample-host-name"}, + ) assert host_result["step_id"] == "manual" assert host_result["errors"]["base"] == "unknown" -async def test_discovery_and_no_devices_discovered( - hass: HomeAssistant, stub_mock_discovery -) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( +async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None: + """Test eiscp discovery with no devices found.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + with mock_discovery([]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" -async def test_discovery_with_exception( - hass: HomeAssistant, empty_mock_discovery -) -> None: - """Test discovery which throws an unexpected exception.""" - init_result = await hass.config_entries.flow.async_init( +async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None: + """Test eiscp discovery with an unexpected exception.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + with mock_discovery(None): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "eiscp_discovery"}, + ) - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" -async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: - """Test discovery with a new and an existing entry.""" - init_result = await hass.config_entries.flow.async_init( +@pytest.mark.usefixtures("mock_setup_entry") +async def test_eiscp_discovery( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test eiscp discovery.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, ) - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(1)) - await discovery_callback(create_connection(2)) - - with ( - patch("pyeiscp.Connection.discover", new=mock_discover), - # Fake it like the first entry was already added - patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"}, ) - assert form_result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema + assert result["data_schema"] is not None + schema = result["data_schema"].schema container = schema["device"].container - assert container == {"id2": "type 2 (host 2)"} + assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} - -async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: - """Test discovery after a selection.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"device": RECEIVER_INFO_2.identifier}, ) - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(42)) - await discovery_callback(create_connection(0)) - - with patch("pyeiscp.Connection.discover", new=mock_discover): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + assert result["step_id"] == "configure_receiver" - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={"device": "id42"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, + ) - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier -async def test_ssdp_discovery_success( - hass: HomeAssistant, default_mock_discovery -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_ssdp_discovery_success(hass: HomeAssistant) -> None: """Test SSDP discovery with valid host.""" discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location="http://192.168.0.101:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", @@ -224,7 +211,7 @@ async def test_ssdp_discovery_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" - select_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "volume_resolution": 200, @@ -233,24 +220,19 @@ async def test_ssdp_discovery_success( }, ) - assert select_result["type"] is FlowResultType.CREATE_ENTRY - assert select_result["data"]["host"] == "192.168.1.100" - assert select_result["result"].unique_id == "id1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO.host + assert result["result"].unique_id == RECEIVER_INFO.identifier async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + ssdp_location="http://192.168.0.101:8080", upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, ssdp_usn="uuid:mock_usn", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", @@ -276,10 +258,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: ssdp_st="mock_st", ) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=OSError, - ): + with mock_discovery(None): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -290,9 +269,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: assert result["reason"] == "unknown" -async def test_ssdp_discovery_host_none_info( - hass: HomeAssistant, stub_mock_discovery -) -> None: +async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", @@ -301,19 +278,18 @@ async def test_ssdp_discovery_host_none_info( ssdp_st="mock_st", ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) + with mock_discovery([]): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "cannot_connect" -async def test_ssdp_discovery_no_location( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: """Test SSDP discovery with no location.""" discovery_info = SsdpServiceInfo( ssdp_location=None, @@ -332,9 +308,7 @@ async def test_ssdp_discovery_no_location( assert result["reason"] == "unknown" -async def test_ssdp_discovery_no_host( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: """Test SSDP discovery with no host.""" discovery_info = SsdpServiceInfo( ssdp_location="http://", @@ -353,9 +327,7 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_no_resolution( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_configure_no_resolution(hass: HomeAssistant) -> None: """Test receiver configure with no resolution set.""" init_result = await hass.config_entries.flow.async_init( @@ -380,9 +352,9 @@ async def test_configure_no_resolution( ) -async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_configure(hass: HomeAssistant) -> None: """Test receiver configure.""" - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -395,7 +367,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) result = await hass.config_entries.flow.async_configure( @@ -437,9 +409,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: } -async def test_configure_invalid_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None: """Test receiver configure with invalid resolution.""" init_result = await hass.config_entries.flow.async_init( @@ -464,22 +434,23 @@ async def test_configure_invalid_resolution_set( ) -async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) + await setup_integration(hass, mock_config_entry) - old_host = config_entry.data[CONF_HOST] - old_options = config_entry.options + old_host = mock_config_entry.data[CONF_HOST] + old_options = mock_config_entry.options - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": receiver_info.host} + result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} ) await hass.async_block_till_done() @@ -494,36 +465,28 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reconfigure_successful" - assert config_entry.data[CONF_HOST] == old_host - assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 + assert mock_config_entry.data[CONF_HOST] == old_host + assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 for option, option_value in old_options.items(): if option == OPTION_VOLUME_RESOLUTION: continue - assert config_entry.options[option] == option_value + assert mock_config_entry.options[option] == option_value -async def test_reconfigure_new_device(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_new_device( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow with new device.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) - - old_unique_id = receiver_info.identifier - - result = await config_entry.start_reconfigure_flow(hass) + await setup_integration(hass, mock_config_entry) - mock_connection = create_connection(2) + old_unique_id = mock_config_entry.unique_id - # Create mock discover that calls callback immediately - async def mock_discover(host, discovery_callback, timeout): - await discovery_callback(mock_connection) + result = await mock_config_entry.start_reconfigure_flow(hass) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): + with mock_discovery([RECEIVER_INFO_2]): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_connection.host} + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) await hass.async_block_till_done() @@ -531,9 +494,10 @@ async def mock_discover(host, discovery_callback, timeout): assert result2["reason"] == "unique_id_mismatch" # unique id should remain unchanged - assert config_entry.unique_id == old_unique_id + assert mock_config_entry.unique_id == old_unique_id +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( "ignore_missing_translations", [ @@ -545,16 +509,15 @@ async def mock_discover(host, discovery_callback, timeout): ] ], ) -async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test options flow.""" + await setup_integration(hass, mock_config_entry) - receiver_info = create_receiver_info(1) - config_entry = create_empty_config_entry() - await setup_integration(hass, config_entry, receiver_info) - - old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + old_volume_resolution = mock_config_entry.options[OPTION_VOLUME_RESOLUTION] - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 4c6ddcca214e99..144947dcbe1e2b 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -2,51 +2,85 @@ from __future__ import annotations -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock +from aioonkyo import Status import pytest -from homeassistant.components.onkyo import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from . import create_empty_config_entry, create_receiver_info, setup_integration +from . import mock_discovery, setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_receiver") async def test_load_unload_entry( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "receiver_infos", + [ + None, + [], + ], +) +async def test_initialization_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + receiver_infos, +) -> None: + """Test initialization failure.""" + with mock_discovery(receiver_infos): + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> None: + """Test connection failure.""" + mock_connect.side_effect = OSError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_no_connection( + +@pytest.mark.usefixtures("mock_receiver") +async def test_reconnect( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], ) -> None: - """Test update options.""" - - config_entry = create_empty_config_entry() - config_entry.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.onkyo.async_interview", - return_value=None, - ), - pytest.raises(ConfigEntryNotReady), - ): - await async_setup_entry(hass, config_entry) + """Test reconnect.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect.reset_mock() + + assert mock_connect.call_count == 0 + + read_queue.put_nowait(None) # Simulate a disconnect + await asyncio.sleep(0) + + assert mock_connect.call_count == 1 diff --git a/tests/components/onkyo/test_media_player.py b/tests/components/onkyo/test_media_player.py new file mode 100644 index 00000000000000..3d22e3b1af847d --- /dev/null +++ b/tests/components/onkyo/test_media_player.py @@ -0,0 +1,230 @@ +"""Test Onkyo media player platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from aioonkyo import Instruction, Zone, command +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.components.onkyo.services import ( + ATTR_HDMI_OUTPUT, + SERVICE_SELECT_HDMI_OUTPUT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "media_player.tx_nr7100" +ENTITY_ID_ZONE_2 = "media_player.tx_nr7100_zone_2" +ENTITY_ID_ZONE_3 = "media_player.tx_nr7100_zone_3" + + +@pytest.fixture(autouse=True) +async def auto_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: AsyncMock, + writes: list[Instruction], +) -> AsyncGenerator[None]: + """Auto setup integration.""" + with ( + patch( + "homeassistant.components.onkyo.media_player.AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME", + 0, + ), + patch("homeassistant.components.onkyo.PLATFORMS", [Platform.MEDIA_PLAYER]), + ): + await setup_integration(hass, mock_config_entry) + writes.clear() + yield + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("action", "action_data", "message"), + [ + (SERVICE_TURN_ON, {}, command.Power(Zone.MAIN, command.Power.Param.ON)), + (SERVICE_TURN_OFF, {}, command.Power(Zone.MAIN, command.Power.Param.STANDBY)), + ( + SERVICE_VOLUME_SET, + {ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + command.Volume(Zone.MAIN, 40), + ), + (SERVICE_VOLUME_UP, {}, command.Volume(Zone.MAIN, command.Volume.Param.UP)), + (SERVICE_VOLUME_DOWN, {}, command.Volume(Zone.MAIN, command.Volume.Param.DOWN)), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: True}, + command.Muting(Zone.MAIN, command.Muting.Param.ON), + ), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: False}, + command.Muting(Zone.MAIN, command.Muting.Param.OFF), + ), + ], +) +async def test_actions( + hass: HomeAssistant, + writes: list[Instruction], + action: str, + action_data: dict, + message: Instruction, +) -> None: + """Test actions.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_ID, **action_data}, + blocking=True, + ) + assert writes[0] == message + + +async def test_select_source(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test select source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "TV"}, + blocking=True, + ) + assert writes[0] == command.InputSource(Zone.MAIN, command.InputSource.Param("12")) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "InvalidSource"}, + blocking=True, + ) + assert not writes + + +async def test_select_sound_mode( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select sound mode.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "THX"}, + blocking=True, + ) + assert writes[0] == command.ListeningMode( + Zone.MAIN, command.ListeningMode.Param("04") + ) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "InvalidMode"}, + blocking=True, + ) + assert not writes + + +async def test_play_media(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test play media (radio preset).""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert writes[0] == command.TunerPreset(Zone.MAIN, 5) + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_2, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_3, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + +async def test_select_hdmi_output( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select hdmi output.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_HDMI_OUTPUT, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HDMI_OUTPUT: "sub"}, + blocking=True, + ) + assert writes[0] == command.HDMIOutput(command.HDMIOutput.Param.BOTH) From 3c70932357bedb48128b9d59ce7e345fc9193d05 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 16:52:25 +0200 Subject: [PATCH 28/28] Use OptionsFlowWithReload in enphase_envoy (#149171) --- homeassistant/components/enphase_envoy/__init__.py | 9 --------- homeassistant/components/enphase_envoy/config_flow.py | 4 ++-- tests/components/enphase_envoy/test_init.py | 3 ++- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index f43d89aa098450..e95ab1179e109a 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -4,7 +4,6 @@ from pyenphase import Envoy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -47,17 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when it is updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool: """Unload a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5b7bb98527c0be..9ba11eafa5d033 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -14,7 +14,7 @@ SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -335,7 +335,7 @@ async def async_step_reconfigure( ) -class EnvoyOptionsFlowHandler(OptionsFlow): +class EnvoyOptionsFlowHandler(OptionsFlowWithReload): """Envoy config flow options handler.""" async def async_step_init( diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index c43be96d8b18e0..2aa18c991a6156 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -509,7 +509,7 @@ async def test_coordinator_interface_information( # verify first time add of mac to connections is in log assert "added connection" in caplog.text - # trigger integration reload by changing options + # update options and reload hass.config_entries.async_update_entry( config_entry, options={ @@ -517,6 +517,7 @@ async def test_coordinator_interface_information( OPTION_DISABLE_KEEP_ALIVE: True, }, ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED