From 1ac16f6dbf765659e37443afeb9b7af43a64b44b Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 02:37:46 -0500 Subject: [PATCH 01/66] Set suggested display precision in La Crosse View (#138355) * Set suggested display precision in La Crosse View * Switch to entity descriptions --- homeassistant/components/lacrosse_view/sensor.py | 9 +++++++++ tests/components/lacrosse_view/test_sensor.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index df66b7ba96a..ea5a82a3df8 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, ), "Humidity": LaCrosseSensorEntityDescription( key="Humidity", @@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, ), "HeatIndex": LaCrosseSensorEntityDescription( key="HeatIndex", @@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = { state_class=SensorStateClass.MEASUREMENT, value_fn=get_value, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindSpeed": LaCrosseSensorEntityDescription( key="WindSpeed", @@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=2, ), "Rain": LaCrosseSensorEntityDescription( key="Rain", @@ -93,12 +97,14 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, + suggested_display_precision=2, ), "WindHeading": LaCrosseSensorEntityDescription( key="WindHeading", translation_key="wind_heading", value_fn=get_value, native_unit_of_measurement=DEGREE, + suggested_display_precision=2, ), "WetDry": LaCrosseSensorEntityDescription( key="WetDry", @@ -117,6 +123,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.HPA, + suggested_display_precision=2, ), "FeelsLike": LaCrosseSensorEntityDescription( key="FeelsLike", @@ -125,6 +132,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), "WindChill": LaCrosseSensorEntityDescription( key="WindChill", @@ -133,6 +141,7 @@ SENSOR_DESCRIPTIONS = { value_fn=get_value, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + suggested_display_precision=2, ), } # map of API returned unit of measurement strings to their corresponding unit of measurement diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 74e9f001792..17ae56ed78d 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -117,7 +117,7 @@ async def test_field_not_supported( (TEST_STRING_SENSOR, "dry", "wet_dry"), (TEST_ALREADY_FLOAT_SENSOR, "-16.5", "heat_index"), (TEST_ALREADY_INT_SENSOR, "2", "wind_speed"), - (TEST_UNITS_OVERRIDE_SENSOR, "-16.6", "temperature"), + (TEST_UNITS_OVERRIDE_SENSOR, "-16.6111111111111", "temperature"), ], ) async def test_field_types( From 737baaef2b93e3a696bbc6751f567426ed75d50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 13 Feb 2025 09:22:05 +0100 Subject: [PATCH 02/66] Improve test coverage for letpot (#138420) --- .../components/letpot/quality_scale.yaml | 2 +- tests/components/letpot/test_init.py | 37 +++++++++++++++- tests/components/letpot/test_switch.py | 44 ++++++++++++++++++- tests/components/letpot/test_time.py | 20 +++++++++ 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0eda413a461..70f3bb52b82 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -44,7 +44,7 @@ rules: log-when-unavailable: todo parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index 178227a6506..e3f78d87dc1 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -2,7 +2,11 @@ from unittest.mock import MagicMock -from letpot.exceptions import LetPotAuthenticationException, LetPotConnectionException +from letpot.exceptions import ( + LetPotAuthenticationException, + LetPotConnectionException, + LetPotException, +) import pytest from homeassistant.config_entries import ConfigEntryState @@ -94,3 +98,34 @@ async def test_get_devices_exceptions( assert mock_config_entry.state is config_entry_state mock_client.get_devices.assert_called_once() mock_device_client.subscribe.assert_not_called() + + +async def test_device_subscribe_authentication_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors if it is not allowed to subscribe to device updates.""" + mock_device_client.subscribe.side_effect = LetPotAuthenticationException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + mock_device_client.subscribe.assert_called_once() + mock_device_client.get_current_status.assert_not_called() + + +async def test_device_refresh_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test config entry errors with retry if getting a device state update fails.""" + mock_device_client.get_current_status.side_effect = LetPotException + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_device_client.get_current_status.assert_called_once() diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index b166d551adb..0ba1f556bc9 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -6,7 +6,11 @@ from letpot.exceptions import LetPotConnectionException, LetPotException import pytest from syrupy import SnapshotAssertion -from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.components.switch import ( + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -32,6 +36,44 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize( + ("service", "parameter_value"), + [ + ( + SERVICE_TURN_ON, + True, + ), + ( + SERVICE_TURN_OFF, + False, + ), + ( + SERVICE_TOGGLE, + False, # Mock switch is on after setup, toggle will turn off + ), + ], +) +async def test_set_switch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, + service: str, + parameter_value: bool, +) -> None: + """Test switch entity turned on/turned off/toggled.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "switch", + service, + blocking=True, + target={"entity_id": "switch.garden_power"}, + ) + + mock_device_client.set_power.assert_awaited_once_with(parameter_value) + + @pytest.mark.parametrize( ("service", "exception", "user_error"), [ diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index 82e69979067..e65ea4532e1 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -33,6 +33,26 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +async def test_set_time( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_device_client: MagicMock, +) -> None: + """Test setting the time entity.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + "time", + SERVICE_SET_VALUE, + service_data={"time": time(hour=7, minute=0)}, + blocking=True, + target={"entity_id": "time.garden_light_on"}, + ) + + mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + + @pytest.mark.parametrize( ("exception", "user_error"), [ From 6bc4f04a079142ccf763d30e6ba19c3170f5a7ee Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Thu, 13 Feb 2025 03:24:28 -0500 Subject: [PATCH 03/66] Handle no_readings in La Crosse View (#138354) * Handle no_readings in La Crosse View * Fixes --- .../components/lacrosse_view/coordinator.py | 28 +++++++--- .../components/lacrosse_view/strings.json | 5 ++ tests/components/lacrosse_view/__init__.py | 22 ++++++++ tests/components/lacrosse_view/test_init.py | 17 ++++++ tests/components/lacrosse_view/test_sensor.py | 56 +++++++++++++++++++ 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 3d741e8f1a8..16d7e8b2bb8 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): try: # Fetch last hour of data for sensor in self.devices: - sensor.data = ( - await self.api.get_sensor_status( - sensor=sensor, - tz=self.hass.config.time_zone, + data = await self.api.get_sensor_status( + sensor=sensor, + tz=self.hass.config.time_zone, + ) + _LOGGER.debug("Got data: %s", data) + + if data_error := data.get("error"): + if data_error == "no_readings": + sensor.data = None + _LOGGER.debug("No readings for %s", sensor.name) + continue + _LOGGER.debug("Error: %s", data_error) + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" ) - )["data"]["current"] - _LOGGER.debug("Got data: %s", sensor.data) + + sensor.data = data["data"]["current"] except HTTPError as error: - raise UpdateFailed from error + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="update_error" + ) from error # Verify that we have permission to read the sensors for sensor in self.devices: diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json index 8dc27ba259e..c5d9a11e49a 100644 --- a/homeassistant/components/lacrosse_view/strings.json +++ b/homeassistant/components/lacrosse_view/strings.json @@ -42,5 +42,10 @@ "name": "Wind chill" } } + }, + "exceptions": { + "update_error": { + "message": "Error updating data" + } } } diff --git a/tests/components/lacrosse_view/__init__.py b/tests/components/lacrosse_view/__init__.py index 860156beb6c..7221fa4c071 100644 --- a/tests/components/lacrosse_view/__init__.py +++ b/tests/components/lacrosse_view/__init__.py @@ -165,3 +165,25 @@ TEST_UNITS_OVERRIDE_SENSOR = Sensor( permissions={"read": True}, model="Test", ) +TEST_NO_READINGS_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "no_readings"}, + permissions={"read": True}, + model="Test", +) +TEST_OTHER_ERROR_SENSOR = Sensor( + name="Test", + device_id="1", + type="Test", + sensor_id="2", + sensor_field_names=["Temperature"], + location=Location(id="1", name="Test"), + data={"error": "some_other_error"}, + permissions={"read": True}, + model="Test", +) diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index af92d0e64f1..0533dd2abee 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -83,6 +83,23 @@ async def test_http_error(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY + config_entry_2 = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry_2.add_to_hass(hass) + + # Start over, let get_devices succeed but get_sensor_status fail + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch("lacrosse_view.LaCrosse.get_devices", return_value=[TEST_SENSOR]), + patch("lacrosse_view.LaCrosse.get_sensor_status", side_effect=HTTPError), + ): + assert not await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 2 + assert entries[1].state is ConfigEntryState.SETUP_RETRY + async def test_new_token(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: """Test new token.""" diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index 17ae56ed78d..e0dc1e5f35f 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -18,6 +18,8 @@ from . import ( TEST_MISSING_FIELD_DATA_SENSOR, TEST_NO_FIELD_SENSOR, TEST_NO_PERMISSION_SENSOR, + TEST_NO_READINGS_SENSOR, + TEST_OTHER_ERROR_SENSOR, TEST_SENSOR, TEST_STRING_SENSOR, TEST_UNITS_OVERRIDE_SENSOR, @@ -204,3 +206,57 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED assert hass.states.get("sensor.test_temperature").state == "unknown" + + +async def test_no_readings(hass: HomeAssistant) -> None: + """Test behavior when there are no readings.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_NO_READINGS_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN] + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert hass.states.get("sensor.test_temperature").state == "unavailable" + + +async def test_other_error(hass: HomeAssistant) -> None: + """Test behavior when there is an error.""" + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA) + config_entry.add_to_hass(hass) + + sensor = TEST_OTHER_ERROR_SENSOR.model_copy() + status = sensor.data + sensor.data = None + + with ( + patch("lacrosse_view.LaCrosse.login", return_value=True), + patch( + "lacrosse_view.LaCrosse.get_devices", + return_value=[sensor], + ), + patch("lacrosse_view.LaCrosse.get_sensor_status", return_value=status), + ): + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert entries + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY From 07c304125aec142087348f2937ef73946e381741 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:37:52 +0100 Subject: [PATCH 04/66] Add error handling to enphase_envoy select platform action (#136698) * Add error handling to enphase_envoy select platform action * Add translation key parameter to exception_handler decorator --- .../components/enphase_envoy/select.py | 4 +- tests/components/enphase_envoy/test_select.py | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 546470a19d5..42b47e5d793 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator -from .entity import EnvoyBaseEntity +from .entity import EnvoyBaseEntity, exception_handler PARALLEL_UPDATES = 1 @@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Return the state of the Enpower switch.""" return self.entity_description.value_fn(self.relay) + @exception_handler async def async_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) @@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): assert self.data.tariff.storage_settings is not None return self.entity_description.value_fn(self.data.tariff.storage_settings) + @exception_handler async def async_select_option(self, option: str) -> None: """Update the relay.""" await self.entity_description.update_fn(self.envoy, option) diff --git a/tests/components/enphase_envoy/test_select.py b/tests/components/enphase_envoy/test_select.py index e13492c7f54..a81a06a3441 100644 --- a/tests/components/enphase_envoy/test_select.py +++ b/tests/components/enphase_envoy/test_select.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from pyenphase.exceptions import EnvoyError import pytest from syrupy.assertion import SnapshotAssertion @@ -17,6 +18,7 @@ from homeassistant.components.enphase_envoy.select import ( from homeassistant.components.select import ATTR_OPTION, DOMAIN as SELECT_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration @@ -157,6 +159,46 @@ async def test_select_relay_modes( ) +@pytest.mark.parametrize( + ("mock_envoy", "relay", "target", "action"), + [("envoy_metered_batt_relay", "NC1", "generator_action", "powered")], + indirect=["mock_envoy"], +) +async def test_update_dry_contact_actions_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + target: str, + relay: str, + action: str, +) -> None: + """Test select platform update dry contact action with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SELECT}." + + assert (dry_contact := mock_envoy.data.dry_contact_settings[relay]) + assert (name := dry_contact.load_name.lower().replace(" ", "_")) + + test_entity = f"{entity_base}{name}_{target}" + + mock_envoy.update_dry_contact.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: action, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ @@ -197,6 +239,44 @@ async def test_select_storage_modes( mock_envoy.set_storage_mode.assert_called_once_with(REVERSE_STORAGE_MODE_MAP[mode]) +@pytest.mark.parametrize( + ("mock_envoy", "use_serial"), + [ + ("envoy_metered_batt_relay", "enpower_654321"), + ("envoy_eu_batt", "envoy_1234"), + ], + indirect=["mock_envoy"], +) +@pytest.mark.parametrize(("mode"), ["backup"]) +async def test_set_storage_modes_with_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, + use_serial: str, + mode: str, +) -> None: + """Test select platform set storage mode with error return.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SELECT]): + await setup_integration(hass, config_entry) + + test_entity = f"{Platform.SELECT}.{use_serial}_storage_mode" + + mock_envoy.set_storage_mode.side_effect = EnvoyError("Test") + with pytest.raises( + HomeAssistantError, + match=f"Failed to execute async_select_option for {test_entity}, host", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: test_entity, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + @pytest.mark.parametrize( ("mock_envoy", "use_serial"), [ From 0a9d134f49170a6e0836c35f81b27212168efc87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 10:28:55 +0100 Subject: [PATCH 05/66] Make descriptions of `data` fields in notify actions UI-friendly (#138431) Also fixes a duplicated period at the end of the second string. --- homeassistant/components/notify/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json index e832bfc248a..b33af360448 100644 --- a/homeassistant/components/notify/strings.json +++ b/homeassistant/components/notify/strings.json @@ -24,7 +24,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } }, @@ -56,7 +56,7 @@ }, "data": { "name": "Data", - "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.." + "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation." } } } From a8f4ab73aebb747adc95f33ced5092d6a8d64471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 13 Feb 2025 12:40:55 +0100 Subject: [PATCH 06/66] Bump hass-nabucasa from 0.90.0 to 0.91.0 (#138441) --- 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 7598dde6cf3..16d340a480b 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.90.0"], + "requirements": ["hass-nabucasa==0.91.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b35d5589182..b49409d9ce7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index c0d83b05f00..e693b6ec9c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.90.0", + "hass-nabucasa==0.91.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 4afa122ba7d..7baea71e608 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.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 c9d118adb8e..92d1a2a62ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 335326545a2..6e24129a0fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.90.0 +hass-nabucasa==0.91.0 # homeassistant.components.conversation hassil==2.2.3 From 6a26d59142dfc14d718450ac2b3277ec0a722784 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Thu, 13 Feb 2025 05:45:09 -0600 Subject: [PATCH 07/66] Add night light brightness level setting to VeSync (#137544) --- homeassistant/components/vesync/__init__.py | 1 + homeassistant/components/vesync/const.py | 4 + homeassistant/components/vesync/select.py | 133 +++++++++++++++++++ homeassistant/components/vesync/strings.json | 10 ++ tests/components/vesync/common.py | 1 + tests/components/vesync/conftest.py | 45 ++++++- tests/components/vesync/test_init.py | 2 + tests/components/vesync/test_select.py | 54 ++++++++ 8 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/vesync/select.py create mode 100644 tests/components/vesync/test_select.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index 4951bdb2dc1..f9371d44507 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -27,6 +27,7 @@ PLATFORMS = [ Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 34454081567..897c8d2b745 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -29,6 +29,10 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" VS_HUMIDIFIER_MODE_MANUAL = "manual" VS_HUMIDIFIER_MODE_SLEEP = "sleep" +NIGHT_LIGHT_LEVEL_BRIGHT = "bright" +NIGHT_LIGHT_LEVEL_DIM = "dim" +NIGHT_LIGHT_LEVEL_OFF = "off" + VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S """Humidifier device types""" diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py new file mode 100644 index 00000000000..c266985fc2b --- /dev/null +++ b/homeassistant/components/vesync/select.py @@ -0,0 +1,133 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .common import rgetattr +from .const import ( + DOMAIN, + NIGHT_LIGHT_LEVEL_BRIGHT, + NIGHT_LIGHT_LEVEL_DIM, + NIGHT_LIGHT_LEVEL_OFF, + VS_COORDINATOR, + VS_DEVICES, + VS_DISCOVERY, +) +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + +VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP = { + 100: NIGHT_LIGHT_LEVEL_BRIGHT, + 50: NIGHT_LIGHT_LEVEL_DIM, + 0: NIGHT_LIGHT_LEVEL_OFF, +} + +HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP = { + v: k for k, v in VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.items() +} + + +@dataclass(frozen=True, kw_only=True) +class VeSyncSelectEntityDescription(SelectEntityDescription): + """Class to describe a Vesync select entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + current_option_fn: Callable[[VeSyncBaseDevice], str] + select_option_fn: Callable[[VeSyncBaseDevice, str], bool] + + +SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=list(VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.values()), + icon="mdi:brightness-6", + exists_fn=lambda device: rgetattr(device, "night_light"), + # The select_option service framework ensures that only options specified are + # accepted. ServiceValidationError gets raised for invalid value. + select_option_fn=lambda device, value: device.set_night_light_brightness( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + ), + # Reporting "off" as the choice for unhandled level. + current_option_fn=lambda device: VS_TO_HA_NIGHT_LIGHT_LEVEL_MAP.get( + device.details.get("night_light_brightness"), NIGHT_LIGHT_LEVEL_OFF + ), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up select entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddConfigEntryEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add select entities.""" + + async_add_entities( + VeSyncSelectEntity(dev, description, coordinator) + for dev in devices + for description in SELECT_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncSelectEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncSelectEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync select device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def current_option(self) -> str | None: + """Return an option.""" + return self.entity_description.current_option_fn(self.device) + + async def async_select_option(self, option: str) -> None: + """Set an option.""" + if await self.hass.async_add_executor_job( + self.entity_description.select_option_fn, self.device, option + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index 3eb2a0c3fd5..2232b16329b 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -56,6 +56,16 @@ "name": "Mist level" } }, + "select": { + "night_light_level": { + "name": "Night light level", + "state": { + "bright": "Bright", + "dim": "Dim", + "off": "[%key:common::state::off%]" + } + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index ee9f9b94052..39a92778727 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -13,6 +13,7 @@ from tests.common import load_fixture, load_json_object_fixture ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" +ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT = "select.humidifier_300s_night_light_level" ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 9ec7bd23fa5..df6ebbdf6e7 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -107,7 +107,7 @@ def outlet_fixture(): @pytest.fixture(name="humidifier") def humidifier_fixture(): - """Create a mock VeSync humidifier fixture.""" + """Create a mock VeSync Classic200S humidifier fixture.""" return Mock( VeSyncHumid200300S, cid="200s-humidifier", @@ -135,6 +135,34 @@ def humidifier_fixture(): ) +@pytest.fixture(name="humidifier_300s") +def humidifier_300s_fixture(): + """Create a mock VeSync Classic300S humidifier fixture.""" + return Mock( + VeSyncHumid200300S, + cid="300s-humidifier", + config={ + "auto_target_humidity": 40, + "display": "true", + "automatic_stop": "true", + }, + details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + device_type="Classic300S", + device_name="Humidifier 300s", + device_status="on", + mist_level=6, + mist_modes=["auto", "manual"], + mode=None, + night_light=True, + sub_device_no=0, + config_module="configModule", + connection_status="online", + current_firm_version="1.0.0", + water_lacks=False, + water_tank_lifted=False, + ) + + @pytest.fixture(name="humidifier_config_entry") async def humidifier_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config @@ -155,6 +183,21 @@ async def humidifier_config_entry( return entry +@pytest.fixture +async def install_humidifier_device( + hass: HomeAssistant, + config_entry: ConfigEntry, + manager, + request: pytest.FixtureRequest, +) -> None: + """Create a mock VeSync config entry with the specified humidifier device.""" + + # Install the defined humidifier + manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index f1fb3931bf9..011545af2ae 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -56,6 +56,7 @@ async def test_async_setup_entry__no_devices( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] @@ -87,6 +88,7 @@ async def test_async_setup_entry__loads_fans( Platform.HUMIDIFIER, Platform.LIGHT, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py new file mode 100644 index 00000000000..30c83c89e0e --- /dev/null +++ b/tests/components/vesync/test_select.py @@ -0,0 +1,54 @@ +"""Tests for the select platform.""" + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.components.vesync.const import NIGHT_LIGHT_LEVEL_DIM +from homeassistant.components.vesync.select import HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant + +from .common import ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_set_nightlight_level( + hass: HomeAssistant, manager, humidifier_300s, install_humidifier_device +) -> None: + """Test set of night light level.""" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT, + ATTR_OPTION: NIGHT_LIGHT_LEVEL_DIM, + }, + blocking=True, + ) + + # Assert that setter API was invoked with the expected translated value + humidifier_300s.set_night_light_brightness.assert_called_once_with( + HA_TO_VS_NIGHT_LIGHT_LEVEL_MAP[NIGHT_LIGHT_LEVEL_DIM] + ) + # Assert that devices were refreshed + manager.update_all_devices.assert_called_once() + + +@pytest.mark.parametrize( + "install_humidifier_device", ["humidifier_300s"], indirect=True +) +async def test_nightlight_level(hass: HomeAssistant, install_humidifier_device) -> None: + """Test the state of night light level select entity.""" + + # The mocked device has night_light_brightness=50 which is "dim" + assert ( + hass.states.get(ENTITY_HUMIDIFIER_300S_NIGHT_LIGHT_SELECT).state + == NIGHT_LIGHT_LEVEL_DIM + ) From e9138a427da8943c174205561f37a73944ce01d3 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 13:00:38 +0100 Subject: [PATCH 08/66] Replace wrong description reference of isy994.send_node_command (#138385) --- homeassistant/components/isy994/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json index 86a1f14ff91..8872226daba 100644 --- a/homeassistant/components/isy994/strings.json +++ b/homeassistant/components/isy994/strings.json @@ -58,7 +58,7 @@ "services": { "send_raw_node_command": { "name": "Send raw node command", - "description": "[%key:component::isy994::options::step::init::description%]", + "description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that aren’t fully supported in Home Assistant yet, such as controls for many NodeServer nodes.", "fields": { "command": { "name": "Command", From 7021175e0da26a6f6144550c035349b2ecb6ff80 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:07:24 +0100 Subject: [PATCH 09/66] Simplify stage 1 in bootstrap (#137668) * Simplify stage 1 in bootstrap * Add timeouts to STAGE 0 * Fix test * Clarify pre import language * Remove timeout for frontend and recorder * Address review --------- Co-authored-by: J. Nick Koston --- homeassistant/bootstrap.py | 132 ++++++++++++++------------------- tests/test_bootstrap.py | 14 ++-- tests/test_circular_imports.py | 14 ++-- 3 files changed, 67 insertions(+), 93 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 58150ae7926..7fd73af0053 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -134,14 +134,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded") LOG_SLOW_STARTUP_INTERVAL = 60 SLOW_STARTUP_CHECK_INTERVAL = 1 +STAGE_0_SUBSTAGE_TIMEOUT = 60 STAGE_1_TIMEOUT = 120 STAGE_2_TIMEOUT = 300 WRAP_UP_TIMEOUT = 300 COOLDOWN_TIME = 60 - -DEBUGGER_INTEGRATIONS = {"debugpy"} - # Core integrations are unconditionally loaded CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"} @@ -172,12 +170,27 @@ FRONTEND_INTEGRATIONS = { # add it here. "backup", } -RECORDER_INTEGRATIONS = { - # Setup after frontend - # To record data - "recorder", -} -DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf") +# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout. +# The substage containing recorder should have no timeout, as it could cancel a database migration. +# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts. +# The substages preceding it should also have no timeout, until we ensure that the recorder +# is not accidentally promoted as a dependency of any of the integrations in them. +# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode. +STAGE_0_INTEGRATIONS = ( + # Load logging and http deps as soon as possible + ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None), + # Setup frontend + ("frontend", FRONTEND_INTEGRATIONS, None), + # Setup recorder + ("recorder", {"recorder"}, None), + # Start up debuggers. Start these first in case they want to wait. + ("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT), + # Zeroconf is used for mdns resolution in aiohttp client helper. + ("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT), +) + +DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb") +# Stage 1 integrations are not to be preimported in bootstrap. STAGE_1_INTEGRATIONS = { # We need to make sure discovery integrations # update their deps before stage 2 integrations @@ -189,9 +202,8 @@ STAGE_1_INTEGRATIONS = { "mqtt_eventstream", # To provide account link implementations "cloud", - # Ensure supervisor is available - "hassio", } + DEFAULT_INTEGRATIONS = { # These integrations are set up unless recovery mode is activated. # @@ -232,22 +244,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = { # These integrations are set up if using the Supervisor "hassio", } + CRITICAL_INTEGRATIONS = { # Recovery mode is activated if these integrations fail to set up "frontend", } -SETUP_ORDER = ( - # Load logging and http deps as soon as possible - ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS), - # Setup frontend - ("frontend", FRONTEND_INTEGRATIONS), - # Setup recorder - ("recorder", RECORDER_INTEGRATIONS), - # Start up debuggers. Start these first in case they want to wait. - ("debugger", DEBUGGER_INTEGRATIONS), -) - # # Storage keys we are likely to load during startup # in order of when we expect to load them. @@ -694,7 +696,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str: return deps_dir -@core.callback def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: """Get domains of components to set up.""" # Filter out the repeating and common config section [homeassistant] @@ -890,69 +891,48 @@ async def _async_set_up_integrations( domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( hass, config ) + stage_2_domains = domains_to_setup.copy() # Initialize recorder if "recorder" in domains_to_setup: recorder.async_initialize_recorder(hass) - pre_stage_domains = [ - (name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER + stage_0_and_1_domains: list[tuple[str, set[str], int | None]] = [ + *( + (name, domain_group & domains_to_setup, timeout) + for name, domain_group, timeout in STAGE_0_INTEGRATIONS + ), + ("stage 1", STAGE_1_INTEGRATIONS & domains_to_setup, STAGE_1_TIMEOUT), ] - # calculate what components to setup in what stage - stage_1_domains: set[str] = set() + _LOGGER.info("Setting up stage 0 and 1") + for name, domain_group, timeout in stage_0_and_1_domains: + if not domain_group: + continue - # Find all dependencies of any dependency of any stage 1 integration that - # we plan on loading and promote them to stage 1. This is done only to not - # get misleading log messages - deps_promotion: set[str] = STAGE_1_INTEGRATIONS - while deps_promotion: - old_deps_promotion = deps_promotion - deps_promotion = set() + _LOGGER.info("Setting up %s: %s", name, domain_group) + to_be_loaded = domain_group.copy() + to_be_loaded.update( + dep + for domain in domain_group + if (integration := integration_cache.get(domain)) is not None + for dep in integration.all_dependencies + ) + async_set_domains_to_be_loaded(hass, to_be_loaded) + stage_2_domains -= to_be_loaded - for domain in old_deps_promotion: - if domain not in domains_to_setup or domain in stage_1_domains: - continue - - stage_1_domains.add(domain) - - if (dep_itg := integration_cache.get(domain)) is None: - continue - - deps_promotion.update(dep_itg.all_dependencies) - - stage_2_domains = domains_to_setup - stage_1_domains - - for name, domain_group in pre_stage_domains: - if domain_group: - stage_2_domains -= domain_group - _LOGGER.info("Setting up %s: %s", name, domain_group) - to_be_loaded = domain_group.copy() - to_be_loaded.update( - dep - for domain in domain_group - if (integration := integration_cache.get(domain)) is not None - for dep in integration.all_dependencies - ) - async_set_domains_to_be_loaded(hass, to_be_loaded) + if timeout is None: await _async_setup_multi_components(hass, domain_group, config) - - # Enables after dependencies when setting up stage 1 domains - async_set_domains_to_be_loaded(hass, stage_1_domains) - - # Start setup - if stage_1_domains: - _LOGGER.info("Setting up stage 1: %s", stage_1_domains) - try: - async with hass.timeout.async_timeout( - STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME - ): - await _async_setup_multi_components(hass, stage_1_domains, config) - except TimeoutError: - _LOGGER.warning( - "Setup timed out for stage 1 waiting on %s - moving forward", - hass._active_tasks, # noqa: SLF001 - ) + else: + try: + async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME): + await _async_setup_multi_components(hass, domain_group, config) + except TimeoutError: + _LOGGER.warning( + "Setup timed out for %s waiting on %s - moving forward", + name, + hass._active_tasks, # noqa: SLF001 + ) # Add after dependencies when setting up stage 2 domains async_set_domains_to_be_loaded(hass, stage_2_domains) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 4317df6cf4a..d554ca9449a 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1090,7 +1090,7 @@ async def test_tasks_logged_that_block_stage_1( patch.object(bootstrap, "STAGE_1_TIMEOUT", 0), patch.object(bootstrap, "COOLDOWN_TIME", 0), patch.object( - bootstrap, "STAGE_1_INTEGRATIONS", [*original_stage_1, "normal_integration"] + bootstrap, "STAGE_1_INTEGRATIONS", {*original_stage_1, "normal_integration"} ), ): await bootstrap._async_set_up_integrations(hass, {"normal_integration": {}}) @@ -1373,11 +1373,11 @@ async def test_pre_import_no_requirements(hass: HomeAssistant) -> None: @pytest.mark.timeout(20) -async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: - """Test that the bootstrap does not preload stage 1 integrations. +async def test_bootstrap_does_not_preimport_stage_1_integrations() -> None: + """Test that the bootstrap does not preimport stage 1 integrations. If this test fails it means that stage1 integrations are being - loaded too soon and will not get their requirements updated + imported too soon and will not get their requirements updated before they are loaded at runtime. """ @@ -1391,13 +1391,9 @@ async def test_bootstrap_does_not_preload_stage_1_integrations() -> None: assert process.returncode == 0 decoded_stdout = stdout.decode() - disallowed_integrations = bootstrap.STAGE_1_INTEGRATIONS.copy() - # zeroconf is a top level dep now - disallowed_integrations.remove("zeroconf") - # Ensure no stage1 integrations have been imported # as a side effect of importing the pre-imports - for integration in disallowed_integrations: + for integration in bootstrap.STAGE_1_INTEGRATIONS: assert f"homeassistant.components.{integration}" not in decoded_stdout diff --git a/tests/test_circular_imports.py b/tests/test_circular_imports.py index dfdee65b2b0..d6e730aae5e 100644 --- a/tests/test_circular_imports.py +++ b/tests/test_circular_imports.py @@ -7,11 +7,8 @@ import pytest from homeassistant.bootstrap import ( CORE_INTEGRATIONS, - DEBUGGER_INTEGRATIONS, DEFAULT_INTEGRATIONS, - FRONTEND_INTEGRATIONS, - LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - RECORDER_INTEGRATIONS, + STAGE_0_INTEGRATIONS, STAGE_1_INTEGRATIONS, ) @@ -21,11 +18,12 @@ from homeassistant.bootstrap import ( "component", sorted( { - *DEBUGGER_INTEGRATIONS, *CORE_INTEGRATIONS, - *LOGGING_AND_HTTP_DEPS_INTEGRATIONS, - *FRONTEND_INTEGRATIONS, - *RECORDER_INTEGRATIONS, + *( + domain + for name, domains, timeout in STAGE_0_INTEGRATIONS + for domain in domains + ), *STAGE_1_INTEGRATIONS, *DEFAULT_INTEGRATIONS, } From 82074a894075bd7795c057274d5a9e9142238295 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Thu, 13 Feb 2025 16:36:07 +0100 Subject: [PATCH 10/66] Starlink migration to `StarlinkConfigEntry` (#137896) * refactor: Utilize custom StarlinkConfigEntry * fix: ruff-format * fix: Init tests * fix: StarlinkConfigEntry in coordinator after recent PRs * fix: CONF_IP_ADDRESS constant * fix: After merge clean up * fix: Naming conventions * feat: Add runtime_data into init test * refactor: Remove runtime_data assert in unload entry test --- homeassistant/components/starlink/__init__.py | 26 ++++++++----------- .../components/starlink/binary_sensor.py | 10 +++---- homeassistant/components/starlink/button.py | 11 +++----- .../components/starlink/coordinator.py | 6 +++-- .../components/starlink/device_tracker.py | 11 +++----- .../components/starlink/diagnostics.py | 9 +++---- homeassistant/components/starlink/sensor.py | 11 +++----- homeassistant/components/starlink/switch.py | 11 +++----- homeassistant/components/starlink/time.py | 11 +++----- tests/components/starlink/test_init.py | 4 +-- 10 files changed, 43 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py index 4528a35858c..0c512bb21c5 100644 --- a/homeassistant/components/starlink/__init__.py +++ b/homeassistant/components/starlink/__init__.py @@ -2,12 +2,10 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -19,21 +17,19 @@ PLATFORMS = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Set up Starlink from a config entry.""" - coordinator = StarlinkUpdateCoordinator(hass, entry) + config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry) + await config_entry.runtime_data.async_config_entry_first_refresh() - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: StarlinkConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py index f5eaf2baba0..e06e79009c3 100644 --- a/homeassistant/components/starlink/binary_sensor.py +++ b/homeassistant/components/starlink/binary_sensor.py @@ -10,26 +10,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkBinarySensorEntity(coordinator, description) + StarlinkBinarySensorEntity(config_entry.runtime_data, description) for description in BINARY_SENSORS ) diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py index dc23e31d8d2..15f35659b49 100644 --- a/homeassistant/components/starlink/button.py +++ b/homeassistant/components/starlink/button.py @@ -10,26 +10,23 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkButtonEntity(coordinator, description) for description in BUTTONS + StarlinkButtonEntity(config_entry.runtime_data, description) + for description in BUTTONS ) diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 4ae771c9582..02d51cd805e 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator] + @dataclass class StarlinkData: @@ -51,9 +53,9 @@ class StarlinkData: class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): """Coordinates updates between all Starlink sensors defined in this file.""" - config_entry: ConfigEntry + config_entry: StarlinkConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None: """Initialize an UpdateCoordinator for a group of sensors.""" self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS]) self.history_stats_start = None diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py index 53e7ab1cee0..dbe31947b55 100644 --- a/homeassistant/components/starlink/device_tracker.py +++ b/homeassistant/components/starlink/device_tracker.py @@ -8,25 +8,22 @@ from homeassistant.components.device_tracker import ( TrackerEntity, TrackerEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_ALTITUDE, DOMAIN -from .coordinator import StarlinkData +from .const import ATTR_ALTITUDE +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkDeviceTrackerEntity(coordinator, description) + StarlinkDeviceTrackerEntity(config_entry.runtime_data, description) for description in DEVICE_TRACKERS ) diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py index c619458b1dd..543fe9d8dde 100644 --- a/homeassistant/components/starlink/diagnostics.py +++ b/homeassistant/components/starlink/diagnostics.py @@ -4,18 +4,15 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry TO_REDACT = {"id", "latitude", "longitude", "altitude"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, config_entry: StarlinkConfigEntry ) -> dict[str, Any]: """Return diagnostics for Starlink config entries.""" - coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return async_redact_data(asdict(coordinator.data), TO_REDACT) + return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index dadbf8a061a..d07e8174b27 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -28,21 +27,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import now -from .const import DOMAIN -from .coordinator import StarlinkData +from .coordinator import StarlinkConfigEntry, StarlinkData from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSensorEntity(coordinator, description) for description in SENSORS + StarlinkSensorEntity(config_entry.runtime_data, description) + for description in SENSORS ) diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index 51603850690..c6dc237643e 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -11,25 +11,22 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all binary sensors for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkSwitchEntity(coordinator, description) for description in SWITCHES + StarlinkSwitchEntity(config_entry.runtime_data, description) + for description in SWITCHES ) diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py index 3540123e1eb..9f564333218 100644 --- a/homeassistant/components/starlink/time.py +++ b/homeassistant/components/starlink/time.py @@ -8,26 +8,23 @@ from datetime import UTC, datetime, time, tzinfo import math from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import StarlinkData, StarlinkUpdateCoordinator +from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator from .entity import StarlinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + config_entry: StarlinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up all time entities for this entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - StarlinkTimeEntity(coordinator, description) for description in TIMES + StarlinkTimeEntity(config_entry.runtime_data, description) + for description in TIMES ) diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py index 7e04c21562a..f15a80771cf 100644 --- a/tests/components/starlink/test_init.py +++ b/tests/components/starlink/test_init.py @@ -33,8 +33,9 @@ async def test_successful_entry(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.runtime_data + assert entry.runtime_data.data assert entry.state is ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] async def test_unload_entry(hass: HomeAssistant) -> None: @@ -59,4 +60,3 @@ async def test_unload_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entry.state is ConfigEntryState.NOT_LOADED - assert entry.entry_id not in hass.data[DOMAIN] From a03c5880021ad510b262d55650332c34721b7822 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:54:29 +0100 Subject: [PATCH 11/66] Mark entity-device-class as done for motionmount integration (#138459) All entities where a device class is available have a device class --- homeassistant/components/motionmount/quality_scale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index f8fee8739e9..2648355c3af 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: Single device per config entry entity-category: todo - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done From d4c5479e503ae17d71e9edbe7e09b2fe57271ef1 Mon Sep 17 00:00:00 2001 From: Maghiel Dijksman Date: Thu, 13 Feb 2025 17:14:56 +0100 Subject: [PATCH 12/66] Fix Tuya unsupported cameras (#136960) --- homeassistant/components/tuya/camera.py | 3 ++ homeassistant/components/tuya/light.py | 14 ++++++ homeassistant/components/tuya/number.py | 9 ++++ homeassistant/components/tuya/select.py | 34 ++++++++++++++ homeassistant/components/tuya/sensor.py | 23 ++++++++++ homeassistant/components/tuya/siren.py | 7 +++ homeassistant/components/tuya/switch.py | 59 +++++++++++++++++++++++++ 7 files changed, 149 insertions(+) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index b07b9e9959e..c04a8a043dc 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -20,6 +20,9 @@ CAMERAS: tuple[str, ...] = ( # Smart Camera (including doorbells) # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sp", + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj", ) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7f4a964f47e..40d0fd73f0e 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -261,6 +261,20 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaLightEntityDescription( + key=DPCode.FLOODLIGHT_SWITCH, + brightness=DPCode.FLOODLIGHT_LIGHTNESS, + name="Floodlight", + ), + TuyaLightEntityDescription( + key=DPCode.BASIC_INDICATOR, + name="Indicator light", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 4e98cf34d4d..ce1f434bcdd 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -174,6 +174,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + NumberEntityDescription( + key=DPCode.BASIC_DEVICE_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 766cdd295f1..0ae49cd127e 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -128,6 +128,40 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SelectEntityDescription( + key=DPCode.IPC_WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="ipc_work_mode", + ), + SelectEntityDescription( + key=DPCode.DECIBEL_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="decibel_sensitivity", + ), + SelectEntityDescription( + key=DPCode.RECORD_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="record_mode", + ), + SelectEntityDescription( + key=DPCode.BASIC_NIGHTVISION, + entity_category=EntityCategory.CONFIG, + translation_key="basic_nightvision", + ), + SelectEntityDescription( + key=DPCode.BASIC_ANTI_FLICKER, + entity_category=EntityCategory.CONFIG, + translation_key="basic_anti_flicker", + ), + SelectEntityDescription( + key=DPCode.MOTION_SENSITIVITY, + entity_category=EntityCategory.CONFIG, + translation_key="motion_sensitivity", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index cb7602e24fe..76825e9c814 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -632,6 +632,29 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + TuyaSensorEntityDescription( + key=DPCode.SENSOR_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.SENSOR_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WIRELESS_ELECTRICITY, + translation_key="battery", + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Fingerbot "szjqr": BATTERY_SENSORS, # Solar Light diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 310385df93d..9c60f7bcaac 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -44,6 +44,13 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SirenEntityDescription( + key=DPCode.SIREN_SWITCH, + ), + ), # CO2 Detector # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy "co2bj": ( diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d0192b41ee6..519a9e83606 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -509,6 +509,65 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Camera - Low power consumption camera + # Undocumented, see https://github.com/home-assistant/core/issues/132844 + "dghsxj": ( + SwitchEntityDescription( + key=DPCode.WIRELESS_BATTERYLOCK, + translation_key="battery_lock", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CRY_DETECTION_SWITCH, + translation_key="cry_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.DECIBEL_SWITCH, + translation_key="sound_detection", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.RECORD_SWITCH, + translation_key="video_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_RECORD, + translation_key="motion_recording", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_PRIVATE, + translation_key="privacy_mode", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_FLIP, + translation_key="flip", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_OSD, + translation_key="time_watermark", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.BASIC_WDR, + translation_key="wide_dynamic_range", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_TRACKING, + translation_key="motion_tracking", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.MOTION_SWITCH, + translation_key="motion_alarm", + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Gardening system # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 "sz": ( From bf27eeb861e33ae21a6920de036e79542b31ecbc Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 13 Feb 2025 13:46:50 -0500 Subject: [PATCH 13/66] Add sonos_websocket to Sonos loggers (#138470) --- homeassistant/components/sonos/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index bfdf0da9dbb..bb3d99c4c93 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", - "loggers": ["soco"], + "loggers": ["soco", "sonos_websocket"], "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"], "ssdp": [ { From 2ea648f8aee3a264700b8c09b32a0d43fa8f6218 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 13 Feb 2025 19:55:04 +0100 Subject: [PATCH 14/66] Replace `config.yaml` with correct `configuration.yaml` in folder_watcher (#138434) --- homeassistant/components/folder_watcher/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json index da1e3c1962a..5b1f72bf254 100644 --- a/homeassistant/components/folder_watcher/strings.json +++ b/homeassistant/components/folder_watcher/strings.json @@ -36,11 +36,11 @@ "issues": { "import_failed_not_allowed_path": { "title": "The Folder Watcher YAML configuration could not be imported", - "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue." + "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue." }, "setup_not_allowed_path": { "title": "The Folder Watcher configuration for {path} could not start", - "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue." + "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue." } }, "entity": { From ab2e075b410cbeeaacd6c3e0241c340f0af004c6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 13 Feb 2025 11:35:58 -0800 Subject: [PATCH 15/66] Bump opower to 0.9.0 (#138433) Co-authored-by: Shay Levy --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index d168cba5752..2da4511c0aa 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.8.9"] + "requirements": ["opower==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 92d1a2a62ab..ba5aeee25df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1595,7 +1595,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e24129a0fe..3ea50ac1d32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.8.9 +opower==0.9.0 # homeassistant.components.oralb oralb-ble==0.17.6 From bbbad90ca29b9e9a356fe166fcf586ede8cf8973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 13 Feb 2025 14:17:06 -0600 Subject: [PATCH 16/66] Fix race configuring zeroconf (#138425) --- homeassistant/bootstrap.py | 4 ++ homeassistant/components/network/__init__.py | 19 ++++++++- homeassistant/components/network/const.py | 2 - homeassistant/components/network/network.py | 13 +++++- homeassistant/components/zeroconf/__init__.py | 24 ++++++----- tests/components/zeroconf/test_init.py | 40 ++++++++++++++++--- tests/conftest.py | 34 +++++++++++----- 7 files changed, 106 insertions(+), 30 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7fd73af0053..7c5cb7dce4c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -150,6 +150,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = { "isal", # Set log levels "logger", + # Ensure network config is available + # before hassio or any other integration is + # loaded that might create an aiohttp client session + "network", # Error logging "system_log", "sentry", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 10046f75127..200cce86997 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -20,7 +20,7 @@ from .const import ( PUBLIC_TARGET_IP, ) from .models import Adapter -from .network import Network, async_get_network +from .network import Network, async_get_loaded_network, async_get_network _LOGGER = logging.getLogger(__name__) @@ -34,6 +34,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: return network.adapters +@callback +def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]: + """Get the network adapter configuration.""" + return async_get_loaded_network(hass).adapters + + @bind_hass async def async_get_source_ip( hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED @@ -74,7 +80,14 @@ async def async_get_enabled_source_ips( hass: HomeAssistant, ) -> list[IPv4Address | IPv6Address]: """Build the list of enabled source ips.""" - adapters = await async_get_adapters(hass) + return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass)) + + +@callback +def async_get_enabled_source_ips_from_adapters( + adapters: list[Adapter], +) -> list[IPv4Address | IPv6Address]: + """Build the list of enabled source ips.""" sources: list[IPv4Address | IPv6Address] = [] for adapter in adapters: if not adapter["enabled"]: @@ -151,5 +164,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_commands, ) + await async_get_network(hass) + async_register_websocket_commands(hass) return True diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py index 120ae9dfd7c..d8c8858be72 100644 --- a/homeassistant/components/network/const.py +++ b/homeassistant/components/network/const.py @@ -12,8 +12,6 @@ DOMAIN: Final = "network" STORAGE_KEY: Final = "core.network" STORAGE_VERSION: Final = 1 -DATA_NETWORK: Final = "network" - ATTR_ADAPTERS: Final = "adapters" ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py index 4158307bb1a..db25bedcaea 100644 --- a/homeassistant/components/network/network.py +++ b/homeassistant/components/network/network.py @@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.singleton import singleton from homeassistant.helpers.storage import Store from homeassistant.util.async_ import create_eager_task +from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_CONFIGURED_ADAPTERS, - DATA_NETWORK, DEFAULT_CONFIGURED_ADAPTERS, + DOMAIN, STORAGE_KEY, STORAGE_VERSION, ) @@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada _LOGGER = logging.getLogger(__name__) +DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN) -@singleton(DATA_NETWORK) + +@callback +def async_get_loaded_network(hass: HomeAssistant) -> Network: + """Get network singleton.""" + return hass.data[DATA_NETWORK] + + +@singleton(DOMAIN) async def async_get_network(hass: HomeAssistant) -> Network: """Get network singleton.""" network = Network(hass) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b748006336c..e80b6b8cfdb 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -141,13 +141,13 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf: return _async_get_instance(hass) -def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf: +def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf: if DOMAIN in hass.data: return cast(HaAsyncZeroconf, hass.data[DOMAIN]) logging.getLogger("zeroconf").setLevel(logging.NOTSET) - zeroconf = HaZeroconf(**zcargs) + zeroconf = HaZeroconf(**_async_get_zc_args(hass)) aio_zc = HaAsyncZeroconf(zc=zeroconf) install_multiple_zeroconf_catcher(zeroconf) @@ -175,12 +175,10 @@ def _async_zc_has_functional_dual_stack() -> bool: ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up Zeroconf and make Home Assistant discoverable.""" - zc_args: dict = {"ip_version": IPVersion.V4Only} - - adapters = await network.async_get_adapters(hass) - +def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]: + """Get zeroconf arguments from config.""" + zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only} + adapters = network.async_get_loaded_adapters(hass) ipv6 = False if _async_zc_has_functional_dual_stack(): if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): @@ -195,7 +193,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else: zc_args["interfaces"] = [ str(source_ip) - for source_ip in await network.async_get_enabled_source_ips(hass) + for source_ip in network.async_get_enabled_source_ips_from_adapters( + adapters + ) if not source_ip.is_loopback and not (isinstance(source_ip, IPv6Address) and source_ip.is_global) and not ( @@ -207,8 +207,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: and zc_args["ip_version"] == IPVersion.V6Only ) ] + return zc_args - aio_zc = _async_get_instance(hass, **zc_args) + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Zeroconf and make Home Assistant discoverable.""" + aio_zc = _async_get_instance(hass) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) zeroconf_types = await async_get_zeroconf(hass) homekit_models = await async_get_homekit(hass) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 3586f54a59a..56262600511 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1090,7 +1090,7 @@ async def test_async_detect_interfaces_setting_non_loopback_route( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( @@ -1178,7 +1178,7 @@ async def test_async_detect_interfaces_setting_empty_route_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1212,7 +1212,7 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( @@ -1263,7 +1263,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_linux( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1292,7 +1292,7 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( patch.object(hass.config_entries.flow, "async_init"), patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), patch( - "homeassistant.components.zeroconf.network.async_get_adapters", + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, ), patch( @@ -1310,6 +1310,36 @@ async def test_async_detect_interfaces_explicitly_set_ipv6_freebsd( ) +@pytest.mark.usefixtures("mock_async_zeroconf") +async def test_async_detect_interfaces_explicitly_before_setup( + hass: HomeAssistant, +) -> None: + """Test interfaces are explicitly set with IPv6 before setup is called.""" + with ( + patch("homeassistant.components.zeroconf.sys.platform", "linux"), + patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, + patch.object(hass.config_entries.flow, "async_init"), + patch.object(zeroconf, "AsyncServiceBrowser", side_effect=service_update_mock), + patch( + "homeassistant.components.zeroconf.network.async_get_loaded_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6, + ), + patch( + "homeassistant.components.zeroconf.AsyncServiceInfo", + side_effect=get_service_info_mock, + ), + ): + # Call before async_setup has been called + await zeroconf.async_get_async_instance(hass) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + assert mock_zc.mock_calls[0] == call( + interfaces=["192.168.1.5", "fe80::dead:beef:dead:beef%3"], + ip_version=IPVersion.All, + ) + + async def test_no_name(hass: HomeAssistant, mock_async_zeroconf: MagicMock) -> None: """Test fallback to Home for mDNS announcement if the name is missing.""" hass.config.location_name = "" diff --git a/tests/conftest.py b/tests/conftest.py index de627925941..7905439028c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1180,15 +1180,31 @@ async def mqtt_mock_entry( @pytest.fixture(autouse=True, scope="session") def mock_network() -> Generator[None]: """Mock network.""" - with patch( - "homeassistant.components.network.util.ifaddr.get_adapters", - return_value=[ - Mock( - nice_name="eth0", - ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], - index=0, - ) - ], + with ( + patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=[ + Mock( + nice_name="eth0", + ips=[Mock(is_IPv6=False, ip="10.10.10.10", network_prefix=24)], + index=0, + ) + ], + ), + patch( + "homeassistant.components.network.async_get_loaded_adapters", + return_value=[ + { + "auto": True, + "default": True, + "enabled": True, + "index": 0, + "ipv4": [{"address": "10.10.10.10", "network_prefix": 24}], + "ipv6": [], + "name": "eth0", + } + ], + ), ): yield From d6b7762dd65c7814f7b816a28f589bb0a3899233 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 13 Feb 2025 22:13:19 +0100 Subject: [PATCH 17/66] Upgrade paho-mqtt API to v2 (#137613) * Upgrade paho-mqtt API to v2 * Refactor on_connect callback * Add tests * Fix Tasmota tests --- homeassistant/components/mqtt/async_client.py | 15 +++- homeassistant/components/mqtt/client.py | 86 ++++++++++++------- homeassistant/components/mqtt/config_flow.py | 12 +-- tests/common.py | 19 ++++ tests/components/mqtt/test_client.py | 83 ++++++++++-------- tests/components/mqtt/test_config_flow.py | 8 +- tests/components/mqtt/test_init.py | 10 ++- tests/components/tasmota/test_common.py | 14 +-- tests/conftest.py | 17 ++-- 9 files changed, 171 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py index 5f90136df44..0467eb3a289 100644 --- a/homeassistant/components/mqtt/async_client.py +++ b/homeassistant/components/mqtt/async_client.py @@ -6,7 +6,14 @@ from functools import lru_cache from types import TracebackType from typing import Self -from paho.mqtt.client import Client as MQTTClient +from paho.mqtt.client import ( + CallbackOnConnect_v2, + CallbackOnDisconnect_v2, + CallbackOnPublish_v2, + CallbackOnSubscribe_v2, + CallbackOnUnsubscribe_v2, + Client as MQTTClient, +) _MQTT_LOCK_COUNT = 7 @@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient): that is not needed since we are running in an async event loop. """ + on_connect: CallbackOnConnect_v2 + on_disconnect: CallbackOnDisconnect_v2 + on_publish: CallbackOnPublish_v2 + on_subscribe: CallbackOnSubscribe_v2 + on_unsubscribe: CallbackOnUnsubscribe_v2 + def setup(self) -> None: """Set up the client. diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 3aca566dbfc..af62851e15b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -311,8 +311,8 @@ class MqttClientSetup: client_id = None transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT) self._client = AsyncMQTTClient( - mqtt.CallbackAPIVersion.VERSION1, - client_id, + callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=client_id, protocol=proto, transport=transport, # type: ignore[arg-type] reconnect_on_failure=False, @@ -476,9 +476,9 @@ class MQTT: mqttc.on_connect = self._async_mqtt_on_connect mqttc.on_disconnect = self._async_mqtt_on_disconnect mqttc.on_message = self._async_mqtt_on_message - mqttc.on_publish = self._async_mqtt_on_callback - mqttc.on_subscribe = self._async_mqtt_on_callback - mqttc.on_unsubscribe = self._async_mqtt_on_callback + mqttc.on_publish = self._async_mqtt_on_publish + mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe + mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe # suppress exceptions at callback mqttc.suppress_exceptions = True @@ -498,7 +498,7 @@ class MQTT: def _async_reader_callback(self, client: mqtt.Client) -> None: """Handle reading data from the socket.""" if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) @callback def _async_start_misc_periodic(self) -> None: @@ -593,7 +593,7 @@ class MQTT: def _async_writer_callback(self, client: mqtt.Client) -> None: """Handle writing data to the socket.""" if (status := client.loop_write()) != 0: - self._async_on_disconnect(status) + self._async_handle_callback_exception(status) def _on_socket_register_write( self, client: mqtt.Client, userdata: Any, sock: SocketType @@ -983,9 +983,9 @@ class MQTT: self, _mqttc: mqtt.Client, _userdata: None, - _flags: dict[str, int], - result_code: int, - properties: mqtt.Properties | None = None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """On connect callback. @@ -993,19 +993,20 @@ class MQTT: message. """ # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - if result_code != mqtt.CONNACK_ACCEPTED: - if result_code in ( - mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD, - mqtt.CONNACK_REFUSED_NOT_AUTHORIZED, - ): + if reason_code.is_failure: + # 24: Continue authentication + # 25: Re-authenticate + # 134: Bad user name or password + # 135: Not authorized + # 140: Bad authentication method + if reason_code.value in (24, 25, 134, 135, 140): self._should_reconnect = False self.hass.async_create_task(self.async_disconnect()) self.config_entry.async_start_reauth(self.hass) _LOGGER.error( "Unable to connect to the MQTT broker: %s", - mqtt.connack_string(result_code), + reason_code.getName(), # type: ignore[no-untyped-call] ) self._async_connection_result(False) return @@ -1016,7 +1017,7 @@ class MQTT: "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) birth: dict[str, Any] @@ -1153,18 +1154,32 @@ class MQTT: self._mqtt_data.state_write_requests.process_write_state_requests(msg) @callback - def _async_mqtt_on_callback( + def _async_mqtt_on_publish( self, _mqttc: mqtt.Client, _userdata: None, mid: int, - _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None, - _properties_reason: mqtt.ReasonCodes | None = None, + _reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None, ) -> None: + """Publish callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_subscribe_unsubscribe( + self, + _mqttc: mqtt.Client, + _userdata: None, + mid: int, + _reason_code: list[mqtt.ReasonCode], + _properties: mqtt.Properties | None, + ) -> None: + """Subscribe / Unsubscribe callback.""" + self._async_mqtt_on_callback(mid) + + @callback + def _async_mqtt_on_callback(self, mid: int) -> None: """Publish / Subscribe / Unsubscribe callback.""" - # The callback signature for on_unsubscribe is different from on_subscribe - # see https://github.com/eclipse/paho.mqtt.python/issues/687 - # properties and reason codes are not used in Home Assistant future = self._async_get_mid_future(mid) if future.done() and (future.cancelled() or future.exception()): # Timed out or cancelled @@ -1180,19 +1195,28 @@ class MQTT: self._pending_operations[mid] = future return future + @callback + def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None: + """Handle a callback exception.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + _LOGGER.warning( + "Error returned from MQTT server: %s", + mqtt.error_string(status), + ) + @callback def _async_mqtt_on_disconnect( self, _mqttc: mqtt.Client, _userdata: None, - result_code: int, + _disconnect_flags: mqtt.DisconnectFlags, + reason_code: mqtt.ReasonCode, properties: mqtt.Properties | None = None, ) -> None: """Disconnected callback.""" - self._async_on_disconnect(result_code) - - @callback - def _async_on_disconnect(self, result_code: int) -> None: if not self.connected: # This function is re-entrant and may be called multiple times # when there is a broken pipe error. @@ -1203,11 +1227,11 @@ class MQTT: self.connected = False async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) _LOGGER.log( - logging.INFO if result_code == 0 else logging.DEBUG, + logging.INFO if reason_code == 0 else logging.DEBUG, "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], self.conf.get(CONF_PORT, DEFAULT_PORT), - result_code, + reason_code, ) @callback diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a9d417fc783..22568b0f2b8 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1023,14 +1023,14 @@ def try_connection( result: queue.Queue[bool] = queue.Queue(maxsize=1) def on_connect( - client_: mqtt.Client, - userdata: None, - flags: dict[str, Any], - result_code: int, - properties: mqtt.Properties | None = None, + _mqttc: mqtt.Client, + _userdata: None, + _connect_flags: mqtt.ConnectFlags, + reason_code: mqtt.ReasonCode, + _properties: mqtt.Properties | None = None, ) -> None: """Handle connection result.""" - result.put(result_code == mqtt.CONNACK_ACCEPTED) + result.put(not reason_code.is_failure) client.on_connect = on_connect diff --git a/tests/common.py b/tests/common.py index 65e84bc6f00..4d767f0611c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -410,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten return intents +class MockMqttReasonCode: + """Class to fake a MQTT ReasonCode.""" + + value: int + is_failure: bool + + def __init__( + self, value: int = 0, is_failure: bool = False, name: str = "Success" + ) -> None: + """Initialize the mock reason code.""" + self.value = value + self.is_failure = is_failure + self._name = name + + def getName(self) -> str: + """Return the name of the reason code.""" + return self._name + + @callback def async_fire_mqtt_message( hass: HomeAssistant, diff --git a/tests/components/mqtt/test_client.py b/tests/components/mqtt/test_client.py index 2faa9310548..b526d70490b 100644 --- a/tests/components/mqtt/test_client.py +++ b/tests/components/mqtt/test_client.py @@ -32,6 +32,7 @@ from .test_common import help_all_subscribe_calls from tests.common import ( MockConfigEntry, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, ) @@ -94,7 +95,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: mqtt_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mqtt_client.on_connect, mqtt_client, None, 0, 0, 0 + mqtt_client.on_connect, mqtt_client, None, 0, MockMqttReasonCode() ), ) mqtt_client.publish = MagicMock(return_value=FakeInfo()) @@ -119,7 +120,7 @@ async def test_mqtt_await_ack_at_disconnect(hass: HomeAssistant) -> None: ) await asyncio.sleep(0) # Simulate late ACK callback from client with mid 100 - mqtt_client.on_publish(0, 0, 100) + mqtt_client.on_publish(0, 0, 100, MockMqttReasonCode(), None) # disconnect the MQTT client await hass.async_stop() await hass.async_block_till_done() @@ -778,10 +779,10 @@ async def test_replaying_payload_same_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() # Simulate a (retained) message played back after reconnecting @@ -908,10 +909,10 @@ async def test_replaying_payload_wildcard_topic( calls_a = [] calls_b = [] mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() mqtt_client_mock.subscribe.assert_called() @@ -1045,7 +1046,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) # Test to subscribe orther topic while the client is not connected await mqtt.async_subscribe(hass, "test/other", record_calls) @@ -1053,7 +1054,7 @@ async def test_restore_subscriptions_on_reconnect( assert ("test/other", 0) not in help_all_subscribe_calls(mqtt_client_mock) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await mock_debouncer.wait() # Assert all subscriptions are performed at the broker assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) @@ -1089,10 +1090,10 @@ async def test_restore_all_active_subscriptions_on_reconnect( unsub() assert mqtt_client_mock.unsubscribe.call_count == 0 - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) # wait for cooldown await mock_debouncer.wait() @@ -1160,27 +1161,37 @@ async def test_logs_error_if_no_connect_broker( ) -> None: """Test for setup failure if connection to broker is missing.""" mqtt_client_mock = setup_with_birth_msg_client_mock - # test with rc = 3 -> broker unavailable - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, 3) - await hass.async_block_till_done() - assert ( - "Unable to connect to the MQTT broker: Connection Refused: broker unavailable." - in caplog.text + # test with reason code = 136 -> server unavailable + mqtt_client_mock.on_disconnect(Mock(), None, None, MockMqttReasonCode()) + mqtt_client_mock.on_connect( + Mock(), + None, + None, + MockMqttReasonCode(value=136, is_failure=True, name="Server unavailable"), ) + await hass.async_block_till_done() + assert "Unable to connect to the MQTT broker: Server unavailable" in caplog.text -@pytest.mark.parametrize("return_code", [4, 5]) +@pytest.mark.parametrize( + "reason_code", + [ + MockMqttReasonCode( + value=134, is_failure=True, name="Bad user name or password" + ), + MockMqttReasonCode(value=135, is_failure=True, name="Not authorized"), + ], +) async def test_triggers_reauth_flow_if_auth_fails( hass: HomeAssistant, setup_with_birth_msg_client_mock: MqttMockPahoClient, - return_code: int, + reason_code: MockMqttReasonCode, ) -> None: """Test re-auth is triggered if authentication is failing.""" mqtt_client_mock = setup_with_birth_msg_client_mock # test with rc = 4 -> CONNACK_REFUSED_NOT_AUTHORIZED and 5 -> CONNACK_REFUSED_BAD_USERNAME_PASSWORD - mqtt_client_mock.on_disconnect(Mock(), None, 0) - mqtt_client_mock.on_connect(Mock(), None, None, return_code) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode(), None) + mqtt_client_mock.on_connect(Mock(), None, None, reason_code) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -1197,7 +1208,9 @@ async def test_handle_mqtt_on_callback( mqtt_client_mock = setup_with_birth_msg_client_mock with patch.object(mqtt_client_mock, "get_mid", return_value=100): # Simulate an ACK for mid == 100, this will call mqtt_mock._async_get_mid_future(mid) - mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) + mqtt_client_mock.on_publish( + mqtt_client_mock, None, 100, MockMqttReasonCode(), None + ) await hass.async_block_till_done() # Make sure the ACK has been received await hass.async_block_till_done() @@ -1219,7 +1232,7 @@ async def test_handle_mqtt_on_callback_after_cancellation( # Simulate the mid future getting a cancellation mqtt_mock()._async_get_mid_future(101).cancel() # Simulate an ACK for mid == 101, being received after the cancellation - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1236,7 +1249,7 @@ async def test_handle_mqtt_on_callback_after_timeout( # Simulate the mid future getting a timeout mqtt_mock()._async_get_mid_future(101).set_exception(asyncio.TimeoutError) # Simulate an ACK for mid == 101, being received after the timeout - mqtt_client_mock.on_publish(mqtt_client_mock, None, 101) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 101, MockMqttReasonCode(), None) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text assert "InvalidStateError" not in caplog.text @@ -1388,7 +1401,7 @@ async def test_handle_mqtt_timeout_on_callback( mock_client.connect = MagicMock( return_value=0, side_effect=lambda *args, **kwargs: hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ), ) @@ -1777,12 +1790,12 @@ async def test_mqtt_subscribes_topics_on_connect( await mqtt.async_subscribe(hass, "still/pending", record_calls, 1) await mock_debouncer.wait() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1837,12 +1850,12 @@ async def test_mqtt_subscribes_wildcard_topics_in_correct_order( # Assert the initial wildcard topic subscription order _assert_subscription_order() - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() # Assert the wildcard topic subscription order after a reconnect @@ -1868,12 +1881,12 @@ async def test_mqtt_discovery_not_subscribes_when_disabled( assert (f"homeassistant/{component}/+/config", 0) not in subscribe_calls assert (f"homeassistant/{component}/+/+/config", 0) not in subscribe_calls - mqtt_client_mock.on_disconnect(Mock(), None, 0) + mqtt_client_mock.on_disconnect(Mock(), None, 0, MockMqttReasonCode()) mqtt_client_mock.reset_mock() mock_debouncer.clear() - mqtt_client_mock.on_connect(Mock(), None, 0, 0) + mqtt_client_mock.on_connect(Mock(), None, 0, MockMqttReasonCode()) await mock_debouncer.wait() subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) @@ -1968,7 +1981,7 @@ async def test_auto_reconnect( mqtt_client_mock.reconnect.reset_mock() mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() mqtt_client_mock.reconnect.side_effect = exception("foo") @@ -1989,7 +2002,7 @@ async def test_auto_reconnect( hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) mqtt_client_mock.disconnect() - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() async_fire_time_changed( @@ -2031,7 +2044,7 @@ async def test_server_sock_connect_and_disconnect( mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_CONN_LOST mqtt_client_mock.on_socket_unregister_write(mqtt_client_mock, None, client) mqtt_client_mock.on_socket_close(mqtt_client_mock, None, client) - mqtt_client_mock.on_disconnect(mqtt_client_mock, None, client) + mqtt_client_mock.on_disconnect(mqtt_client_mock, None, None, MockMqttReasonCode()) await hass.async_block_till_done() mock_debouncer.clear() unsub() @@ -2169,4 +2182,4 @@ async def test_loop_write_failure( # Final for the disconnect callback await hass.async_block_till_done() - assert "Disconnected from MQTT server test-broker:1883" in caplog.text + assert "Error returned from MQTT server: The connection was lost." in caplog.text diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 1a4ca4bcf19..de70fd32763 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -28,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.hassio import HassioServiceInfo -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockMqttReasonCode from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient ADD_ON_DISCOVERY_INFO = { @@ -143,16 +143,16 @@ def mock_try_connection_success() -> Generator[MqttMockPahoClient]: def loop_start(): """Simulate connect on loop start.""" - mock_client().on_connect(mock_client, None, None, 0) + mock_client().on_connect(mock_client, None, None, MockMqttReasonCode(), None) def _subscribe(topic, qos=0): mid = get_mid() - mock_client().on_subscribe(mock_client, 0, mid) + mock_client().on_subscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) def _unsubscribe(topic): mid = get_mid() - mock_client().on_unsubscribe(mock_client, 0, mid) + mock_client().on_unsubscribe(mock_client, 0, mid, [MockMqttReasonCode()], None) return (0, mid) with patch( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b2dd3d048ec..af9975de1ea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -45,6 +45,7 @@ from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockMqttReasonCode, async_fire_mqtt_message, async_fire_time_changed, mock_restore_cache, @@ -1572,6 +1573,7 @@ async def test_subscribe_connection_status( setup_with_birth_msg_client_mock: MqttMockPahoClient, ) -> None: """Test connextion status subscription.""" + mqtt_client_mock = setup_with_birth_msg_client_mock mqtt_connected_calls_callback: list[bool] = [] mqtt_connected_calls_async: list[bool] = [] @@ -1589,7 +1591,7 @@ async def test_subscribe_connection_status( assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1603,12 +1605,12 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True # Mock disconnect status - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() assert mqtt.is_connected(hass) is False @@ -1618,7 +1620,7 @@ async def test_subscribe_connection_status( # Mock connect status mock_debouncer.clear() - mqtt_client_mock.on_connect(None, None, 0, 0) + mqtt_client_mock.on_connect(None, None, 0, MockMqttReasonCode()) await mock_debouncer.wait() assert mqtt.is_connected(hass) is True diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 4d2c821fff4..674ae316ecc 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -27,7 +27,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from tests.common import async_fire_mqtt_message +from tests.common import MockMqttReasonCode, async_fire_mqtt_message from tests.typing import MqttMockHAClient, MqttMockPahoClient, WebSocketGenerator DEFAULT_CONFIG = { @@ -165,7 +165,7 @@ async def help_test_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -174,7 +174,7 @@ async def help_test_availability_when_connection_lost( # Reconnected to MQTT server -> state still unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -226,7 +226,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Disconnected from MQTT server -> state changed to unavailable mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -235,7 +235,7 @@ async def help_test_deep_sleep_availability_when_connection_lost( # Reconnected to MQTT server -> state no longer unavailable mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -478,7 +478,7 @@ async def help_test_availability_poll_state( # Disconnected from MQTT server mqtt_mock.connected = False - mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_disconnect(None, None, 0, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() @@ -486,7 +486,7 @@ async def help_test_availability_poll_state( # Reconnected to MQTT server mqtt_mock.connected = True - mqtt_client_mock.on_connect(None, None, None, 0) + mqtt_client_mock.on_connect(None, None, None, MockMqttReasonCode()) await hass.async_block_till_done() await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/conftest.py b/tests/conftest.py index 7905439028c..7d9fa7eda2e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,7 @@ from .common import ( # noqa: E402, isort:skip CLIENT_ID, INSTANCES, MockConfigEntry, + MockMqttReasonCode, MockUser, async_fire_mqtt_message, async_test_home_assistant, @@ -969,17 +970,23 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: def _async_fire_mqtt_message(topic, payload, qos, retain): async_fire_mqtt_message(hass, topic, payload or b"", qos, retain) mid = get_mid() - hass.loop.call_soon(mock_client.on_publish, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_publish, Mock(), 0, mid, MockMqttReasonCode(), None + ) return FakeInfo(mid) def _subscribe(topic, qos=0): mid = get_mid() - hass.loop.call_soon(mock_client.on_subscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_subscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _unsubscribe(topic): mid = get_mid() - hass.loop.call_soon(mock_client.on_unsubscribe, 0, 0, mid) + hass.loop.call_soon( + mock_client.on_unsubscribe, Mock(), 0, mid, [MockMqttReasonCode()], None + ) return (0, mid) def _connect(*args, **kwargs): @@ -988,7 +995,7 @@ def mqtt_client_mock(hass: HomeAssistant) -> Generator[MqttMockPahoClient]: # the behavior. mock_client.reconnect() hass.loop.call_soon_threadsafe( - mock_client.on_connect, mock_client, None, 0, 0, 0 + mock_client.on_connect, mock_client, None, 0, MockMqttReasonCode() ) mock_client.on_socket_open( mock_client, None, Mock(fileno=Mock(return_value=-1)) @@ -1065,7 +1072,7 @@ async def _mqtt_mock_entry( # connected set to True to get a more realistic behavior when subscribing mock_mqtt_instance.connected = True - mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) + mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, MockMqttReasonCode()) async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() From 621bcccef7c60428de7f7f1d5b3bca07317ce0ec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 13 Feb 2025 22:51:14 +0100 Subject: [PATCH 18/66] Remove scan interval option from Synology DSM (#138490) remove scan interval option --- homeassistant/components/synology_dsm/__init__.py | 6 +++++- homeassistant/components/synology_dsm/config_flow.py | 9 --------- homeassistant/components/synology_dsm/const.py | 1 - homeassistant/components/synology_dsm/coordinator.py | 11 +---------- homeassistant/components/synology_dsm/strings.json | 2 -- tests/components/synology_dsm/test_config_flow.py | 3 --- tests/components/synology_dsm/test_init.py | 3 +++ 7 files changed, 9 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 0b8b8731f8f..97095f5d299 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -10,7 +10,7 @@ from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import SynologyDSMNotLoggedInException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr @@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None}, ) + if CONF_SCAN_INTERVAL in entry.options: + current_options = {**entry.options} + current_options.pop(CONF_SCAN_INTERVAL) + hass.config_entries.async_update_entry(entry, options=current_options) # Continue setup api = SynoApi(hass, entry) diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index b4453366718..58784862305 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -33,14 +33,12 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, @@ -67,7 +65,6 @@ from .const import ( DEFAULT_BACKUP_PATH, DEFAULT_PORT, DEFAULT_PORT_SSL, - DEFAULT_SCAN_INTERVAL, DEFAULT_SNAPSHOT_QUALITY, DEFAULT_TIMEOUT, DEFAULT_USE_SSL, @@ -458,12 +455,6 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow): data_schema = vol.Schema( { - vol.Required( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): cv.positive_int, vol.Required( CONF_SNAPSHOT_QUALITY, default=self.config_entry.options.get( diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index dbee85b99d6..8fb436e8fa6 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -48,7 +48,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_PORT = 5000 DEFAULT_PORT_SSL = 5001 # Options -DEFAULT_SCAN_INTERVAL = 15 # min DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15) DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED DEFAULT_BACKUP_PATH = "ha_backup" diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 30d1260ef32..1b3e21090b8 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -14,14 +14,12 @@ from synology_dsm.exceptions import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi, raise_config_entry_auth_error from .const import ( - DEFAULT_SCAN_INTERVAL, SIGNAL_CAMERA_SOURCE_CHANGED, SYNOLOGY_AUTH_FAILED_EXCEPTIONS, SYNOLOGY_CONNECTION_EXCEPTIONS, @@ -122,14 +120,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]): api: SynoApi, ) -> None: """Initialize DataUpdateCoordinator for central device.""" - super().__init__( - hass, - entry, - api, - timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) + super().__init__(hass, entry, api, timedelta(minutes=15)) @async_re_login_on_expired async def _async_update_data(self) -> None: diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index d6d40be3fea..c14f8da1037 100644 --- a/homeassistant/components/synology_dsm/strings.json +++ b/homeassistant/components/synology_dsm/strings.json @@ -68,8 +68,6 @@ "step": { "init": { "data": { - "scan_interval": "Minutes between scans", - "timeout": "Timeout (seconds)", "snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)", "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]", "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]" diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index b63ce6c2e18..b25cf7a81ac 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -27,7 +27,6 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -681,14 +680,12 @@ async def test_options_flow( result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - CONF_SCAN_INTERVAL: 2, CONF_SNAPSHOT_QUALITY: 0, CONF_BACKUP_PATH: "my_nackup_path", CONF_BACKUP_SHARE: "/ha_backup", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.options[CONF_SCAN_INTERVAL] == 2 assert config_entry.options[CONF_SNAPSHOT_QUALITY] == 0 assert config_entry.options[CONF_BACKUP_PATH] == "my_nackup_path" assert config_entry.options[CONF_BACKUP_SHARE] == "/ha_backup" diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 7eaafc98437..7fe58719aa4 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_MAC, CONF_PASSWORD, CONF_PORT, + CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -108,6 +109,7 @@ async def test_config_entry_migrations( CONF_PASSWORD: PASSWORD, CONF_MAC: MACS[0], }, + options={CONF_SCAN_INTERVAL: 30}, ) entry.add_to_hass(hass) @@ -118,5 +120,6 @@ async def test_config_entry_migrations( assert await hass.config_entries.async_setup(entry.entry_id) assert entry.data[CONF_VERIFY_SSL] == DEFAULT_VERIFY_SSL + assert CONF_SCAN_INTERVAL not in entry.options assert entry.options[CONF_BACKUP_SHARE] is None assert entry.options[CONF_BACKUP_PATH] is None From 00e98954e4ea8c41e043a50420e9e944c2f59e80 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 14 Feb 2025 01:52:33 +0200 Subject: [PATCH 19/66] Bump aiowebostv to 0.6.2 (#138488) --- homeassistant/components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 174e8025dd0..5fbcf759ee3 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.6.1"], + "requirements": ["aiowebostv==0.6.2"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index ba5aeee25df..1bfef744049 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -422,7 +422,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ea50ac1d32..f6080e96729 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -404,7 +404,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webostv -aiowebostv==0.6.1 +aiowebostv==0.6.2 # homeassistant.components.withings aiowithings==3.1.5 From 099adebcb68b08db715065aad68586c4fb49aa22 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 14 Feb 2025 01:04:39 +0100 Subject: [PATCH 20/66] Bump ZHA to 0.0.49 to fix Tuya TRV issues (#138492) Bump ZHA to 0.0.49 --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 821159afb22..54de60b8669 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.48"], + "requirements": ["zha==0.0.49"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 1bfef744049..551bc833a43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3137,7 +3137,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6080e96729..a9bad901ecb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2526,7 +2526,7 @@ zeroconf==0.144.1 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.48 +zha==0.0.49 # homeassistant.components.zwave_js zwave-js-server-python==0.60.0 From 6a4f5188b1b4549c7d3a6709b0e0212f70c1e385 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 14 Feb 2025 01:30:53 +0100 Subject: [PATCH 21/66] Bump PyViCare to 2.42.1 (#138494) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 489d4accb8a..96935ba4ba7 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.0"] + "requirements": ["PyViCare==2.42.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 551bc833a43..b4b190acda3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9bad901ecb..ce23da1ec81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.0 +PyViCare==2.42.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 83f8a4454d042d66e0520bffccd5816660039945 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Fri, 14 Feb 2025 09:14:44 +0000 Subject: [PATCH 22/66] squeezebox bump pysqueezebox to 0.12.0 (#138205) * bump pysqueezebox to 0.12.0 * python3 -m script.gen_requirements_all --- homeassistant/components/squeezebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 09eaa4026f4..e9b89291749 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.11.1"] + "requirements": ["pysqueezebox==0.12.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4b190acda3..5b87b3c73c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2337,7 +2337,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce23da1ec81..d27c5a29b51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1909,7 +1909,7 @@ pyspcwebgw==0.7.0 pyspeex-noise==1.0.2 # homeassistant.components.squeezebox -pysqueezebox==0.11.1 +pysqueezebox==0.12.0 # homeassistant.components.suez_water pysuezV2==2.0.3 From 51beb21fe461dabca2f5734667a2c8e7905e3a0b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 10:19:00 +0100 Subject: [PATCH 23/66] Bump hass-nabucasa from 0.91.0 to 0.92.0 (#138510) --- 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 16d340a480b..4e99d08afb5 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.91.0"], + "requirements": ["hass-nabucasa==0.92.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b49409d9ce7..997a2167654 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ fnv-hash-fast==1.2.2 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 habluetooth==3.21.1 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250210.0 diff --git a/pyproject.toml b/pyproject.toml index e693b6ec9c5..7b40570015d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "fnv-hash-fast==1.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.91.0", + "hass-nabucasa==0.92.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 7baea71e608..139f0c168f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.2.2 -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.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 5b87b3c73c0..33a78a36da7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1106,7 +1106,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d27c5a29b51..d2dc9851b62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -947,7 +947,7 @@ habiticalib==0.3.7 habluetooth==3.21.1 # homeassistant.components.cloud -hass-nabucasa==0.91.0 +hass-nabucasa==0.92.0 # homeassistant.components.conversation hassil==2.2.3 From d82dd9e7e6792a1397dccdadf1a1c54d5e40ba99 Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Fri, 14 Feb 2025 11:25:04 +0200 Subject: [PATCH 24/66] Bump pyseventeentrack to 1.0.2 (#138506) Bump pyseventeentrack version --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index a130fbe9aee..34019208a14 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.1"] + "requirements": ["pyseventeentrack==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 33a78a36da7..209fa514a97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2286,7 +2286,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2dc9851b62..1b05c7b2db2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pysensibo==1.1.0 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.1 +pyseventeentrack==1.0.2 # homeassistant.components.sia pysiaalarm==3.1.1 From b9148d6368bd8323e77cc5097209f34027a6f8f0 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 10:37:56 +0100 Subject: [PATCH 25/66] Improve descriptions of snooz.transition_xx actions (#138403) The current action descriptions of the snooz integration are easy to misunderstand and result in wrong translations. This commit replaces them with the wording from the online docs, slightly adapted for the UI that already displays the units and ranges. --- homeassistant/components/snooz/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index 94ca434e589..ca252b2117c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -27,25 +27,25 @@ "services": { "transition_on": { "name": "Transition on", - "description": "Transitions to a target volume level over time.", + "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.", "fields": { "duration": { "name": "Transition duration", - "description": "Time it takes to reach the target volume level." + "description": "Time to transition to the target volume." }, "volume": { "name": "Target volume", - "description": "If not specified, the volume level is read from the device." + "description": "Relative volume level. If not specified, the setting on the device is used." } } }, "transition_off": { "name": "Transition off", - "description": "Transitions volume off over time.", + "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.", "fields": { "duration": { "name": "[%key:component::snooz::services::transition_on::fields::duration::name%]", - "description": "Time it takes to turn off." + "description": "Time to complete the transition." } } } From 9f9aeb4cce3fc59ca0cc3c37a3c8c0106033ffc8 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:10:08 +0100 Subject: [PATCH 26/66] Add entity category to non primary entities for motionmount integration (#138436) Add entity category to non primary entities --- homeassistant/components/motionmount/binary_sensor.py | 2 ++ homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index c9d76ebb8d5..d0d6825ee40 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -6,6 +6,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,6 +32,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 2648355c3af..7df450d88f3 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -56,7 +56,7 @@ rules: dynamic-devices: status: exempt comment: Single device per config entry - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: todo entity-translations: done diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 4950e5d6662..9ca8d2b0731 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -6,6 +6,7 @@ import motionmount from motionmount import MotionMountSystemError from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -47,6 +48,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): "internal", ] _attr_translation_key = "motionmount_error_status" + _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry From 4d3a4015edb3edf6d0865fec730ecd48ad34205e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 11:39:04 +0100 Subject: [PATCH 27/66] =?UTF-8?q?Update=20quality=20scale=20to=20platinum?= =?UTF-8?q?=20=F0=9F=8F=86=EF=B8=8F=20for=20Bring!=20integration=20(#13820?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update documentation status in bring quality_scale.yaml * Update quality scale * options flow exempt --- homeassistant/components/bring/manifest.json | 1 + .../components/bring/quality_scale.yaml | 28 +++++++++++-------- script/hassfest/quality_scale.py | 1 - 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index b846cb1c5ca..f292b10f7dc 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["bring_api"], + "quality_scale": "platinum", "requirements": ["bring-api==1.0.2"] } diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml index 58e67ab0e11..2d7d67be12e 100644 --- a/homeassistant/components/bring/quality_scale.yaml +++ b/homeassistant/components/bring/quality_scale.yaml @@ -10,9 +10,9 @@ rules: config-flow: done dependency-transparency: done docs-actions: done - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: The integration registers no events @@ -26,8 +26,10 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: @@ -46,13 +48,15 @@ rules: discovery: status: exempt comment: Integration is a service and has no devices. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Integration is a service and has no devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index e5eee2f4157..60a5f073538 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1286,7 +1286,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "brottsplatskartan", "browser", "brunt", - "bring", "bryant_evolution", "bsblan", "bt_home_hub_5", From f407dbd35c11f8701947bf6205ee4417ca5720eb Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 12:46:41 +0100 Subject: [PATCH 28/66] Disable less used entities by default in MotionMount integration (#138509) * Mark sensors as disabled by default as most users won't need them * Mark entity-disabled-by-default as done * Enable disabled entities during tests --- homeassistant/components/motionmount/binary_sensor.py | 1 + homeassistant/components/motionmount/quality_scale.yaml | 2 +- homeassistant/components/motionmount/sensor.py | 1 + tests/components/motionmount/test_sensor.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py index d0d6825ee40..4bb880311f9 100644 --- a/homeassistant/components/motionmount/binary_sensor.py +++ b/homeassistant/components/motionmount/binary_sensor.py @@ -33,6 +33,7 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOVING _attr_translation_key = "motionmount_is_moving" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 7df450d88f3..765cdd7e945 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -58,7 +58,7 @@ rules: comment: Single device per config entry entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: todo diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 9ca8d2b0731..28fe921d9ac 100644 --- a/homeassistant/components/motionmount/sensor.py +++ b/homeassistant/components/motionmount/sensor.py @@ -49,6 +49,7 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity): ] _attr_translation_key = "motionmount_error_status" _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False def __init__( self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry diff --git a/tests/components/motionmount/test_sensor.py b/tests/components/motionmount/test_sensor.py index bb68c67ce62..0320e62d640 100644 --- a/tests/components/motionmount/test_sensor.py +++ b/tests/components/motionmount/test_sensor.py @@ -14,6 +14,7 @@ from tests.common import MockConfigEntry MAC = bytes.fromhex("c4dd57f8a55f") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.mark.parametrize( ("system_status", "state"), [ From efd7ddeb89f643241ffe78680bfb00b063de4667 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 14 Feb 2025 13:06:07 +0100 Subject: [PATCH 29/66] Improve tests of removing and unloading config entries (#138432) * Improve tests of removing and unloading config entries * Fix unnecessary coroutine --- tests/test_config_entries.py | 54 +++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index cf022c42e94..bf2280790fa 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -462,7 +462,15 @@ async def test_remove_entry( assert result return result - mock_remove_entry = AsyncMock(return_value=None) + remove_entry_calls = [] + + async def mock_remove_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> None: + """Mock removing an entry.""" + # Check that the entry is not yet removed from config entries + assert hass.config_entries.async_get_entry(entry.entry_id) + remove_entry_calls.append(None) entity = MockEntity(unique_id="1234", name="Test Entity") @@ -522,7 +530,7 @@ async def test_remove_entry( assert result == {"require_restart": False} # Check the remove callback was invoked. - assert mock_remove_entry.call_count == 1 + assert len(remove_entry_calls) == 1 # Check that config entry was removed. assert manager.async_entry_ids() == ["test1", "test3"] @@ -2611,29 +2619,49 @@ async def test_entry_setup_invalid_state( assert entry.state is state -async def test_entry_unload_succeed( - hass: HomeAssistant, manager: config_entries.ConfigEntries +@pytest.mark.parametrize( + ("unload_result", "expected_result", "expected_state", "has_runtime_data"), + [ + (True, True, config_entries.ConfigEntryState.NOT_LOADED, False), + (False, False, config_entries.ConfigEntryState.LOADED, True), + ], +) +async def test_entry_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + unload_result: bool, + expected_result: bool, + expected_state: config_entries.ConfigEntryState, + has_runtime_data: bool, ) -> None: """Test that we can unload an entry.""" - unloads_called = [] + unload_entry_calls = [] - async def verify_runtime_data(*args): + @callback + def verify_runtime_data() -> None: """Verify runtime data.""" assert entry.runtime_data == 2 - unloads_called.append(args) - return True + + async def async_unload_entry( + hass: HomeAssistant, entry: config_entries.ConfigEntry + ) -> bool: + """Mock unload entry.""" + unload_entry_calls.append(None) + verify_runtime_data() + assert entry.state is config_entries.ConfigEntryState.LOADED + return unload_result entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) entry.add_to_hass(hass) entry.async_on_unload(verify_runtime_data) entry.runtime_data = 2 - mock_integration(hass, MockModule("comp", async_unload_entry=verify_runtime_data)) + mock_integration(hass, MockModule("comp", async_unload_entry=async_unload_entry)) - assert await manager.async_unload(entry.entry_id) - assert len(unloads_called) == 2 - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert not hasattr(entry, "runtime_data") + assert await manager.async_unload(entry.entry_id) == expected_result + assert len(unload_entry_calls) == 1 + assert entry.state is expected_state + assert hasattr(entry, "runtime_data") == has_runtime_data @pytest.mark.parametrize( From fa4ebeb6805ee3d215e03d03b1cbea2ef421faf4 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:11:32 +0100 Subject: [PATCH 30/66] Bump py-synologydsm-api to 2.6.3 (#138516) bump py-synologydsm-api to 2.6.3 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index a083fa5a15f..d076d843c36 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.6.2"], + "requirements": ["py-synologydsm-api==2.6.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 209fa514a97..fe2127bcab0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1749,7 +1749,7 @@ py-schluter==0.1.7 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b05c7b2db2..8b9cc8455e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1447,7 +1447,7 @@ py-nightscout==1.2.2 py-sucks==0.9.10 # homeassistant.components.synology_dsm -py-synologydsm-api==2.6.2 +py-synologydsm-api==2.6.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From fae68c8ad580b103aea5f2ace77d1cf00a65e417 Mon Sep 17 00:00:00 2001 From: RJPoelstra <36924801+RJPoelstra@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:47:36 +0100 Subject: [PATCH 31/66] Add icon translation to MotionMount integration (#138520) * Add icon translation for error sensor * Mark icon-translations as done --- homeassistant/components/motionmount/icons.json | 12 ++++++++++++ .../components/motionmount/quality_scale.yaml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/motionmount/icons.json diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json new file mode 100644 index 00000000000..8d6d867f4d0 --- /dev/null +++ b/homeassistant/components/motionmount/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "motionmount_error_status": { + "default": "mdi:alert-circle-outline", + "state": { + "none": "mdi:check-circle-outline" + } + } + } + } +} diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml index 765cdd7e945..8b210931eaf 100644 --- a/homeassistant/components/motionmount/quality_scale.yaml +++ b/homeassistant/components/motionmount/quality_scale.yaml @@ -61,7 +61,7 @@ rules: entity-disabled-by-default: done entity-translations: done exception-translations: done - icon-translations: todo + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt From 48f58c7d497be2804a75eab178a5a8d833a50e87 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 13:52:22 +0100 Subject: [PATCH 32/66] Fix action descriptions in Xiaomi Miio integration (#138476) * Fix action description in Xiaomi Miio integration Correct several missing descriptions, wrong references to completely different actions, resulting duplicates and copy & paste errors. Make the grammar more consistent across all strings. Make one occurrence of "xiaomi miio" consistent by capitalizing. * Apply suggestions from @CFenner review Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> * Change "on a light" to "of a light", remove wrong comma * Change "turn off" to "turning off" according to OED --------- Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com> --- .../components/xiaomi_miio/strings.json | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index 75563b07559..bd3b3499689 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the xiaomi miio entity." + "description": "Name of the Xiaomi Miio entity." } } }, @@ -365,7 +365,7 @@ }, "light_set_delayed_turn_off": { "name": "Light set delayed turn off", - "description": "Delayed turn off.", + "description": "Sets the delayed turning off of a light.", "fields": { "entity_id": { "name": "Entity ID", @@ -373,7 +373,7 @@ }, "time_period": { "name": "Time period", - "description": "Time period for the delayed turn off." + "description": "Time period for the delayed turning off." } } }, @@ -398,8 +398,8 @@ } }, "light_night_light_mode_on": { - "name": "Night light mode on", - "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode on", + "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -408,8 +408,8 @@ } }, "light_night_light_mode_off": { - "name": "Night light mode off", - "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).", + "name": "Light night light mode off", + "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -419,7 +419,7 @@ }, "light_eyecare_mode_on": { "name": "Light eyecare mode on", - "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]", + "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -429,7 +429,7 @@ }, "light_eyecare_mode_off": { "name": "Light eyecare mode off", - "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]", + "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).", "fields": { "entity_id": { "name": "Entity ID", @@ -439,7 +439,7 @@ }, "remote_learn_command": { "name": "Remote learn command", - "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", + "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.", "fields": { "slot": { "name": "Slot", @@ -447,21 +447,21 @@ }, "timeout": { "name": "Timeout", - "description": "Define the timeout, before which the command must be learned." + "description": "Define the timeout before which the command must be learned." } } }, "remote_set_led_on": { "name": "Remote set LED on", - "description": "Turns on blue LED." + "description": "Turns on the remote’s blue LED." }, "remote_set_led_off": { "name": "Remote set LED off", - "description": "Turns off blue LED." + "description": "Turns off the remote’s blue LED." }, "switch_set_wifi_led_on": { "name": "Switch set Wi-Fi LED on", - "description": "Turns the Wi-Fi LED on.", + "description": "Turns on the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", @@ -471,7 +471,7 @@ }, "switch_set_wifi_led_off": { "name": "Switch set Wi-Fi LED off", - "description": "Turns the Wi-Fi LED off.", + "description": "Turns off the Wi-Fi LED of a switch.", "fields": { "entity_id": { "name": "Entity ID", From 371490a4705e1e54e012d8372acf243f69e9f19f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 13:57:27 +0100 Subject: [PATCH 33/66] Add sensor platform to LetPot integration (#138491) * Add sensor platform to LetPot integration * Handle support in description supported_fn, use common string * Update homeassistant/components/letpot/switch.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/sensor.py * Update homeassistant/components/letpot/strings.json * Fix translation key in snapshot * snapshot no quotes --------- Co-authored-by: Josef Zweck --- homeassistant/components/letpot/__init__.py | 2 +- homeassistant/components/letpot/entity.py | 9 ++ homeassistant/components/letpot/icons.json | 5 + .../components/letpot/quality_scale.yaml | 4 +- homeassistant/components/letpot/sensor.py | 110 ++++++++++++++++++ homeassistant/components/letpot/strings.json | 5 + homeassistant/components/letpot/switch.py | 52 ++++----- .../letpot/snapshots/test_sensor.ambr | 104 +++++++++++++++++ tests/components/letpot/test_sensor.py | 28 +++++ 9 files changed, 288 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/letpot/sensor.py create mode 100644 tests/components/letpot/snapshots/test_sensor.ambr create mode 100644 tests/components/letpot/test_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index bc84c22d4a2..dc322d5641b 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,7 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index b4d505f4092..5e2c46fee84 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -1,18 +1,27 @@ """Base class for LetPot entities.""" from collections.abc import Callable, Coroutine +from dataclasses import dataclass from typing import Any, Concatenate from letpot.exceptions import LetPotConnectionException, LetPotException from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import LetPotDeviceCoordinator +@dataclass(frozen=True, kw_only=True) +class LetPotEntityDescription(EntityDescription): + """Description for all LetPot entities.""" + + supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True + + class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): """Defines a base LetPot entity.""" diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 2a2b727adcd..60cba78fa1c 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,10 @@ { "entity": { + "sensor": { + "water_level": { + "default": "mdi:water-percent" + } + }, "switch": { "alarm_sound": { "default": "mdi:bell-ring", diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 70f3bb52b82..0fdaca18717 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -59,8 +59,8 @@ rules: docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: todo - entity-category: todo - entity-device-class: todo + entity-category: done + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py new file mode 100644 index 00000000000..b0b113eb063 --- /dev/null +++ b/homeassistant/components/letpot/sensor.py @@ -0,0 +1,110 @@ +"""Support for LetPot sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +LETPOT_TEMPERATURE_UNIT_HA_UNIT = { + TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS, + TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, +} + + +@dataclass(frozen=True, kw_only=True) +class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription): + """Describes a LetPot sensor entity.""" + + native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None] + value_fn: Callable[[LetPotDeviceStatus], StateType] + + +SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( + LetPotSensorEntityDescription( + key="temperature", + value_fn=lambda status: status.temperature_value, + native_unit_of_measurement_fn=( + lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[ + status.temperature_unit or TemperatureUnit.CELSIUS + ] + ), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.TEMPERATURE + in coordinator.device_client.device_features + ), + ), + LetPotSensorEntityDescription( + key="water_level", + translation_key="water_level", + value_fn=lambda status: status.water_level, + native_unit_of_measurement_fn=lambda _: PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + supported_fn=( + lambda coordinator: DeviceFeature.WATER_LEVEL + in coordinator.device_client.device_features + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot sensor entities based on a device features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotSensorEntity(coordinator, description) + for description in SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotSensorEntity(LetPotEntity, SensorEntity): + """Defines a LetPot sensor entity.""" + + entity_description: LetPotSensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotSensorEntityDescription, + ) -> None: + """Initialize LetPot sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the native unit of measurement.""" + return self.entity_description.native_unit_of_measurement_fn( + self.coordinator.data + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 12913085644..0cb79ce711c 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "sensor": { + "water_level": { + "name": "Water level" + } + }, "switch": { "alarm_sound": { "name": "Alarm sound" diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 41150d1b1e9..0b00318c53b 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -from .entity import LetPotEntity, exception_handler +from .entity import LetPotEntity, LetPotEntityDescription, exception_handler # Each change pushes a 'full' device status with the change. The library will cache # pending changes to avoid overwriting, but try to avoid a lot of parallelism. @@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class LetPotSwitchEntityDescription(SwitchEntityDescription): +class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription): """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] -BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( +SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( + LetPotSwitchEntityDescription( + key="alarm_sound", + translation_key="alarm_sound", + value_fn=lambda status: status.system_sound, + set_value_fn=lambda device_client, value: device_client.set_sound(value), + entity_category=EntityCategory.CONFIG, + supported_fn=lambda coordinator: coordinator.data.system_sound is not None, + ), + LetPotSwitchEntityDescription( + key="auto_mode", + translation_key="auto_mode", + value_fn=lambda status: status.water_mode == 1, + set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + entity_category=EntityCategory.CONFIG, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_AUTO + in coordinator.device_client.device_features + ), + ), LetPotSwitchEntityDescription( key="power", translation_key="power", @@ -44,20 +63,6 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ), ) -ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="alarm_sound", - translation_key="alarm_sound", - value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), - entity_category=EntityCategory.CONFIG, -) -AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription( - key="auto_mode", - translation_key="auto_mode", - value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), - entity_category=EntityCategory.CONFIG, -) async def async_setup_entry( @@ -69,19 +74,10 @@ async def async_setup_entry( coordinators = entry.runtime_data entities: list[SwitchEntity] = [ LetPotSwitchEntity(coordinator, description) - for description in BASE_SWITCHES + for description in SWITCHES for coordinator in coordinators + if description.supported_fn(coordinator) ] - entities.extend( - LetPotSwitchEntity(coordinator, ALARM_SWITCH) - for coordinator in coordinators - if coordinator.data.system_sound is not None - ) - entities.extend( - LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH) - for coordinator in coordinators - if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features - ) async_add_entities(entities) diff --git a/tests/components/letpot/snapshots/test_sensor.ambr b/tests/components/letpot/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..5d123cf6ce0 --- /dev/null +++ b/tests/components/letpot/snapshots/test_sensor.ambr @@ -0,0 +1,104 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_water_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden Water level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/letpot/test_sensor.py b/tests/components/letpot/test_sensor.py new file mode 100644 index 00000000000..a527d062ca7 --- /dev/null +++ b/tests/components/letpot/test_sensor.py @@ -0,0 +1,28 @@ +"""Test sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 7dd678ccdf13d7074319aec2dc556b43e7719214 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 14 Feb 2025 14:12:49 +0100 Subject: [PATCH 34/66] Update frontend to 20250214.0 (#138521) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 912ce508e00..c8506335e16 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250210.0"] + "requirements": ["home-assistant-frontend==20250214.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 997a2167654..ed1a1f68621 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -37,7 +37,7 @@ habluetooth==3.21.1 hass-nabucasa==0.92.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 home-assistant-intents==2025.2.5 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index fe2127bcab0..b02763bd82b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1146,7 +1146,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b9cc8455e7..cdd252d7091 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -975,7 +975,7 @@ hole==0.8.0 holidays==0.66 # homeassistant.components.frontend -home-assistant-frontend==20250210.0 +home-assistant-frontend==20250214.0 # homeassistant.components.conversation home-assistant-intents==2025.2.5 From 23d43b23ee0bdb9dfe41eee87ca3cff0577249ad Mon Sep 17 00:00:00 2001 From: Josh Gustafson Date: Fri, 14 Feb 2025 08:03:47 -0700 Subject: [PATCH 35/66] Bump arcam-fmj to 1.8.0 (#138422) * arcam_fmj: bump arcam-fmj to 1.8.0 * Revert castings --------- Co-authored-by: Franck Nijhof --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 39d289f9cb1..944c70c1217 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.5.2"], + "requirements": ["arcam-fmj==1.8.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index b02763bd82b..43f850d14ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -497,7 +497,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdd252d7091..f2877dfacfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -467,7 +467,7 @@ apsystems-ez1==2.4.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.5.2 +arcam-fmj==1.8.0 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 7bd2c1d710712e5b79877c9e9dae24a6e3c437bd Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 16:20:19 +0100 Subject: [PATCH 36/66] Refactor and add tests to image platform of Habitica (#135897) --- homeassistant/components/habitica/image.py | 14 ++- .../test_image/test_image_platform.1.png | Bin 0 -> 70 bytes .../test_image/test_image_platform.png | Bin 0 -> 70 bytes tests/components/habitica/test_image.py | 99 ++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png create mode 100644 tests/components/habitica/__snapshots__/test_image/test_image_platform.png create mode 100644 tests/components/habitica/test_image.py diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f1ade2cac44..1669f124bc7 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -43,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity): translation_key=HabiticaImageEntity.AVATAR, ) _attr_content_type = "image/png" - _current_appearance: Avatar | None = None + _avatar: Avatar | None = None _cache: bytes | None = None def __init__( @@ -55,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity): super().__init__(coordinator, self.entity_description) ImageEntity.__init__(self, hass) self._attr_image_last_updated = dt_util.utcnow() + self._avatar = extract_avatar(self.coordinator.data.user) def _handle_coordinator_update(self) -> None: """Check if equipped gear and other things have changed since last avatar image generation.""" - new_appearance = extract_avatar(self.coordinator.data.user) - if self._current_appearance != new_appearance: - self._current_appearance = new_appearance + if self._avatar != self.coordinator.data.user: + self._avatar = extract_avatar(self.coordinator.data.user) self._attr_image_last_updated = dt_util.utcnow() self._cache = None @@ -69,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity): async def async_image(self) -> bytes | None: """Return cached bytes, otherwise generate new avatar.""" - if not self._cache and self._current_appearance: - self._cache = await self.coordinator.generate_avatar( - self._current_appearance - ) + if not self._cache and self._avatar: + self._cache = await self.coordinator.generate_avatar(self._avatar) return self._cache diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.1.png new file mode 100644 index 0000000000000000000000000000000000000000..5bb8c9d9f091c7a448a220a122933a61ded065d1 GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8HA{P-`=z|79XV4!w9 Q5h%gn>FVdQ&MBb@0IqossQ>@~ literal 0 HcmV?d00001 diff --git a/tests/components/habitica/__snapshots__/test_image/test_image_platform.png b/tests/components/habitica/__snapshots__/test_image/test_image_platform.png new file mode 100644 index 0000000000000000000000000000000000000000..8e9b046ee05dbf00e565c46dda27eb844c562b4e GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZYBRYf8c{W11l>NL;Q)4 Qmw*xsp00i_>zopr0M?)o)c^nh literal 0 HcmV?d00001 diff --git a/tests/components/habitica/test_image.py b/tests/components/habitica/test_image.py new file mode 100644 index 00000000000..17089f57bd7 --- /dev/null +++ b/tests/components/habitica/test_image.py @@ -0,0 +1,99 @@ +"""Tests for the Habitica image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from io import BytesIO +import sys +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from habiticalib import HabiticaUserResponse +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.extensions.image import PNGImageSnapshotExtension + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@pytest.mark.skipif( + sys.platform != "linux", reason="linux only" +) # Pillow output on win/mac is different +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + hass_client: ClientSessionGenerator, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test image platform.""" + freezer.move_to("2024-09-20T22:00:00.000") + with patch( + "homeassistant.components.habitica.coordinator.BytesIO", + ) as avatar: + avatar.side_effect = [ + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdac\xfc\xcf\xc0\xf0\x1f\x00\x05\x05\x02\x00_\xc8\xf1\xd2\x00\x00\x00\x00IEND\xaeB`\x82" + ), + BytesIO( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\xdacd`\xf8\xff\x1f\x00\x03\x07\x02\x000&\xc7a\x00\x00\x00\x00IEND\xaeB`\x82" + ), + ] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.test_user_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) + + habitica.get_user.return_value = HabiticaUserResponse.from_json( + load_fixture("rogue_fixture.json", DOMAIN) + ) + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get("image.test_user_avatar")) + assert state.state == "2024-09-20T22:01:00+00:00" + + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + + assert (await resp.read()) == snapshot( + extension_class=PNGImageSnapshotExtension + ) From 28ea55aac0ebfa63859e4dea1e3b3ee9328e30d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 09:27:16 -0600 Subject: [PATCH 37/66] Bump aiohttp-asyncmdnsresolver to 0.1.1 (#138534) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ed1a1f68621..b7592bf0f05 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodhcpwatcher==1.1.0 aiodiscover==2.6.0 aiodns==3.2.0 aiohasupervisor==0.3.0 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.2 aiohttp==3.11.12 aiohttp_cors==0.7.0 diff --git a/pyproject.toml b/pyproject.toml index 7b40570015d..553ced3da43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "aiohttp==3.11.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.2", - "aiohttp-asyncmdnsresolver==0.1.0", + "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "astral==2.2", "async-interrupt==1.2.1", diff --git a/requirements.txt b/requirements.txt index 139f0c168f1..2b7290fa042 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ aiohasupervisor==0.3.0 aiohttp==3.11.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.2 -aiohttp-asyncmdnsresolver==0.1.0 +aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 astral==2.2 async-interrupt==1.2.1 From 5dc1689e7c5157f03665563b87e89624eaf0642f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 14 Feb 2025 18:06:17 +0100 Subject: [PATCH 38/66] Update action descriptions of weather integration (#138540) --- homeassistant/components/weather/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 85d331f5bd0..31e644b32e3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -90,17 +90,17 @@ "services": { "get_forecasts": { "name": "Get forecasts", - "description": "Get weather forecasts.", + "description": "Retrieves the forecast from selected weather services.", "fields": { "type": { "name": "Forecast type", - "description": "Forecast type: daily, hourly or twice daily." + "description": "The scope of the weather forecast." } } }, "get_forecast": { "name": "Get forecast", - "description": "Get weather forecast.", + "description": "Retrieves the forecast from a selected weather service.", "fields": { "type": { "name": "[%key:component::weather::services::get_forecasts::fields::type::name%]", @@ -111,12 +111,12 @@ }, "issues": { "deprecated_service_weather_get_forecast": { - "title": "Detected use of deprecated service weather.get_forecast", + "title": "Detected use of deprecated action weather.get_forecast", "fix_flow": { "step": { "confirm": { "title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]", - "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue." + "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue." } } } From 11aa08cf74c722ed310705bac70140b77b7f50e2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 14 Feb 2025 19:56:32 +0100 Subject: [PATCH 39/66] =?UTF-8?q?Set=20quality=20scale=20to=20platinum=20?= =?UTF-8?q?=F0=9F=8F=86=EF=B8=8F=20for=20Habitica=20integration=20(#136076?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/habitica/manifest.json | 1 + homeassistant/components/habitica/quality_scale.yaml | 2 +- script/hassfest/quality_scale.py | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index a58bd1296e0..48b6997239e 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", "loggers": ["habiticalib"], + "quality_scale": "platinum", "requirements": ["habiticalib==0.3.7"] } diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml index 9eadba496f2..1752e67cf46 100644 --- a/homeassistant/components/habitica/quality_scale.yaml +++ b/homeassistant/components/habitica/quality_scale.yaml @@ -51,7 +51,7 @@ rules: status: exempt comment: No supportable devices. docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 60a5f073538..12b5932695d 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1535,7 +1535,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "gstreamer", "gtfs", "guardian", - "habitica", "harman_kardon_avr", "harmony", "hassio", From d99044572a1ea6a047975bf91b5c29002cb1963d Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Fri, 14 Feb 2025 14:03:21 -0500 Subject: [PATCH 40/66] Improved auth failure handling in Nice G.O. (#136607) --- .../components/nice_go/coordinator.py | 4 +- homeassistant/components/nice_go/cover.py | 26 ++---- homeassistant/components/nice_go/light.py | 26 ++---- homeassistant/components/nice_go/switch.py | 26 ++---- homeassistant/components/nice_go/util.py | 66 ++++++++++++++ tests/components/nice_go/test_cover.py | 85 ++++++++++++++++++- tests/components/nice_go/test_light.py | 85 ++++++++++++++++++- tests/components/nice_go/test_switch.py | 85 ++++++++++++++++++- 8 files changed, 335 insertions(+), 68 deletions(-) create mode 100644 homeassistant/components/nice_go/util.py diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py index e486263fbe5..ffdd9dbd518 100644 --- a/homeassistant/components/nice_go/coordinator.py +++ b/homeassistant/components/nice_go/coordinator.py @@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): ) try: if datetime.now().timestamp() >= expiry_time: - await self._update_refresh_token() + await self.update_refresh_token() else: await self.api.authenticate_refresh( self.refresh_token, async_get_clientsession(self.hass) @@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): else: self.async_set_updated_data(devices) - async def _update_refresh_token(self) -> None: + async def update_refresh_token(self) -> None: """Update the refresh token with Nice G.O. API.""" _LOGGER.debug("Updating the refresh token with Nice G.O. API") try: diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py index 03124971410..b9b39711a01 100644 --- a/homeassistant/components/nice_go/cover.py +++ b/homeassistant/components/nice_go/cover.py @@ -2,21 +2,17 @@ from typing import Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, CoverEntityFeature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry DEVICE_CLASSES = { "WallStation": CoverDeviceClass.GARAGE, @@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity): """Return if cover is closing.""" return self.data.barrier_status == "closing" + @retry("close_cover_error") async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" if self.is_closed: return - try: - await self.coordinator.api.close_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="close_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.close_barrier(self._device_id) + @retry("open_cover_error") async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" if self.is_opened: return - try: - await self.coordinator.api.open_barrier(self._device_id) - except (ApiError, ClientError) as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="open_cover_error", - translation_placeholders={"exception": str(err)}, - ) from err + await self.coordinator.api.open_barrier(self._device_id) diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py index 5b06c02f5db..bf283ed6eff 100644 --- a/homeassistant/components/nice_go/light.py +++ b/homeassistant/components/nice_go/light.py @@ -3,23 +3,19 @@ import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.light import ColorMode, LightEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity): assert self.data.light_status is not None return self.data.light_status + @retry("light_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - try: - await self.coordinator.api.light_on(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_on(self._device_id) + @retry("light_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - try: - await self.coordinator.api.light_off(self._device_id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="light_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.light_off(self._device_id) diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py index e81ea489d2f..f043a23eab5 100644 --- a/homeassistant/components/nice_go/switch.py +++ b/homeassistant/components/nice_go/switch.py @@ -5,23 +5,19 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING, Any -from aiohttp import ClientError -from nice_go import ApiError - from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, KNOWN_UNSUPPORTED_DEVICE_TYPES, SUPPORTED_DEVICE_TYPES, UNSUPPORTED_DEVICE_WARNING, ) from .coordinator import NiceGOConfigEntry from .entity import NiceGOEntity +from .util import retry _LOGGER = logging.getLogger(__name__) @@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity): assert self.data.vacation_mode is not None return self.data.vacation_mode + @retry("switch_on_error") async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - try: - await self.coordinator.api.vacation_mode_on(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_on_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_on(self.data.id) + @retry("switch_off_error") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - try: - await self.coordinator.api.vacation_mode_off(self.data.id) - except (ApiError, ClientError) as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="switch_off_error", - translation_placeholders={"exception": str(error)}, - ) from error + await self.coordinator.api.vacation_mode_off(self.data.id) diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py new file mode 100644 index 00000000000..02dee6b0ac1 --- /dev/null +++ b/homeassistant/components/nice_go/util.py @@ -0,0 +1,66 @@ +"""Utilities for Nice G.O.""" + +from collections.abc import Callable, Coroutine +from functools import wraps +from typing import Any, Protocol, runtime_checkable + +from aiohttp import ClientError +from nice_go import ApiError, AuthFailedError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import UpdateFailed + +from .const import DOMAIN + + +@runtime_checkable +class _ArgsProtocol(Protocol): + coordinator: Any + hass: Any + + +def retry[_R, **P]( + translation_key: str, +) -> Callable[ + [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]] +]: + """Retry decorator to handle API errors.""" + + def decorator( + func: Callable[P, Coroutine[Any, Any, _R]], + ) -> Callable[P, Coroutine[Any, Any, _R]]: + @wraps(func) + async def wrapper(*args: P.args, **kwargs: P.kwargs): + instance = args[0] + if not isinstance(instance, _ArgsProtocol): + raise TypeError("First argument must have correct attributes") + try: + return await func(*args, **kwargs) + except (ApiError, ClientError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except AuthFailedError: + # Try refreshing token and retry + try: + await instance.coordinator.update_refresh_token() + return await func(*args, **kwargs) + except (ApiError, ClientError, UpdateFailed) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + except (AuthFailedError, ConfigEntryAuthFailed) as err: + instance.coordinator.config_entry.async_start_reauth(instance.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"exception": str(err)}, + ) from err + + return wrapper + + return decorator diff --git a/tests/components/nice_go/test_cover.py b/tests/components/nice_go/test_cover.py index f90c2d438b0..542b1717d88 100644 --- a/tests/components/nice_go/test_cover.py +++ b/tests/components/nice_go/test_cover.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError from freezegun.api import FrozenDateTimeFactory -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -154,3 +154,86 @@ async def test_cover_exceptions( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.COVER]) + + def _open_side_effect(*args, **kwargs): + if mock_nice_go.open_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 5: + raise AuthFailedError + if mock_nice_go.open_barrier.call_count == 6: + raise ApiError + + def _close_side_effect(*args, **kwargs): + if mock_nice_go.close_barrier.call_count <= 3: + raise AuthFailedError + if mock_nice_go.close_barrier.call_count == 4: + raise ApiError + + mock_nice_go.open_barrier.side_effect = _open_side_effect + mock_nice_go.close_barrier.side_effect = _close_side_effect + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.open_barrier.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.close_barrier.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.open_barrier.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error opening the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_1"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error closing the barrier"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_garage_2"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.open_barrier.call_count == 6 + assert mock_nice_go.close_barrier.call_count == 4 diff --git a/tests/components/nice_go/test_light.py b/tests/components/nice_go/test_light.py index b170a0ee3ab..2bc9de59b2b 100644 --- a/tests/components/nice_go/test_light.py +++ b/tests/components/nice_go/test_light.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from syrupy import SnapshotAssertion @@ -160,3 +160,86 @@ async def test_unsupported_device_type( "Please create an issue with your device model in additional info" in caplog.text ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.LIGHT]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.light_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.light_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.light_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.light_off.call_count == 4: + raise ApiError + + mock_nice_go.light_on.side_effect = _on_side_effect + mock_nice_go.light_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.light_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.light_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.light_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_garage_1_light"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the light"): + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.test_garage_2_light"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.light_on.call_count == 6 + assert mock_nice_go.light_off.call_count == 4 diff --git a/tests/components/nice_go/test_switch.py b/tests/components/nice_go/test_switch.py index d3a2141eb2b..cab009c5b94 100644 --- a/tests/components/nice_go/test_switch.py +++ b/tests/components/nice_go/test_switch.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from aiohttp import ClientError -from nice_go import ApiError +from nice_go import ApiError, AuthFailedError import pytest from homeassistant.components.switch import ( @@ -88,3 +88,86 @@ async def test_error( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +async def test_auth_failed_error( + hass: HomeAssistant, + mock_nice_go: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that if an auth failed error occurs, the integration attempts a token refresh and a retry before throwing an error.""" + + await setup_integration(hass, mock_config_entry, [Platform.SWITCH]) + + def _on_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_on.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 5: + raise AuthFailedError + if mock_nice_go.vacation_mode_on.call_count == 6: + raise ApiError + + def _off_side_effect(*args, **kwargs): + if mock_nice_go.vacation_mode_off.call_count <= 3: + raise AuthFailedError + if mock_nice_go.vacation_mode_off.call_count == 4: + raise ApiError + + mock_nice_go.vacation_mode_on.side_effect = _on_side_effect + mock_nice_go.vacation_mode_off.side_effect = _off_side_effect + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 1 + assert mock_nice_go.vacation_mode_on.call_count == 2 + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 2 + assert mock_nice_go.vacation_mode_off.call_count == 2 + + # Try again, but this time the auth failed error should not be raised + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 3 + assert mock_nice_go.vacation_mode_on.call_count == 4 + + # One more time but with an ApiError instead of AuthFailed + + with pytest.raises(HomeAssistantError, match="Error while turning on the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_garage_1_vacation_mode"}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError, match="Error while turning off the switch"): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_garage_2_vacation_mode"}, + blocking=True, + ) + + assert mock_nice_go.authenticate.call_count == 5 + assert mock_nice_go.vacation_mode_on.call_count == 6 + assert mock_nice_go.vacation_mode_off.call_count == 4 From 2bfe96dded803ecd1ed0f65cba729eb2adf40dc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 14 Feb 2025 20:21:01 +0100 Subject: [PATCH 41/66] Add Home Connect action with recognized programs and options (#130662) * Added recognized options to Home Connect actions * Fix ruff * Fix strings.json * Fix dishwasher typo * Improved test_bsh_key_transformations * Add missing return types * Added descriptions * Remove custom options * Fixes * Merge the 4 services (select, start, set options for active or selected program) And deprecate the original ones * Delete stale snapshots * Clean up logic after service validation * Make deprecated actions issues fixable And delete issue on entry unload * Fixes and improvements Co-authored-by: Martin Hjelmare * Improvements Co-authored-by: Martin Hjelmare * Fix name and descriptions * Add `affects_to` to strings and service.yaml * Add missing periods at strings * Fix Co-authored-by: Norbert Rittel * Add tests to check if the flow removes the deprecated action issue --------- Co-authored-by: Martin Hjelmare Co-authored-by: Norbert Rittel --- .../components/home_connect/__init__.py | 284 +++- .../components/home_connect/const.py | 249 +++- .../components/home_connect/icons.json | 3 + .../components/home_connect/manifest.json | 2 +- .../components/home_connect/select.py | 20 +- .../components/home_connect/services.yaml | 526 ++++++++ .../components/home_connect/strings.json | 1174 ++++++++++++----- tests/components/home_connect/conftest.py | 68 +- .../home_connect/snapshots/test_init.ambr | 79 ++ tests/components/home_connect/test_init.py | 281 +++- 10 files changed, 2331 insertions(+), 355 deletions(-) create mode 100644 tests/components/home_connect/snapshots/test_init.ambr diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index becc78cef90..59a33f01bcb 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -2,11 +2,20 @@ from __future__ import annotations +from collections.abc import Awaitable +from datetime import timedelta import logging from typing import Any, cast from aiohomeconnect.client import Client as HomeConnectClient -from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey +from aiohomeconnect.model import ( + ArrayOfOptions, + CommandKey, + Option, + OptionKey, + ProgramKey, + SettingKey, +) from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol @@ -19,34 +28,84 @@ from homeassistant.helpers import ( device_registry as dr, ) from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import ( + AFFECTS_TO_ACTIVE_PROGRAM, + AFFECTS_TO_SELECTED_PROGRAM, + ATTR_AFFECTS_TO, ATTR_KEY, ATTR_PROGRAM, ATTR_UNIT, ATTR_VALUE, DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP, + PROGRAM_ENUM_OPTIONS, SERVICE_OPTION_ACTIVE, SERVICE_OPTION_SELECTED, SERVICE_PAUSE_PROGRAM, SERVICE_RESUME_PROGRAM, SERVICE_SELECT_PROGRAM, + SERVICE_SET_PROGRAM_AND_OPTIONS, SERVICE_SETTING, SERVICE_START_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_PROGRAM, SVE_TRANSLATION_PLACEHOLDER_VALUE, + TRANSLATION_KEYS_PROGRAMS_MAP, ) from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .utils import get_dict_from_home_connect_error +from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, + OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, + OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool, + OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool, + OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool, + OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool, + OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool, + OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int, + OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool, + OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool, + }.items() +} + +TIME_PROGRAM_OPTIONS = { + bsh_key_to_translation_key(key): ( + key, + value, + ) + for key, value in { + OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, + OptionKey.BSH_COMMON_DURATION: cv.time_period_str, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, + }.items() +} + + SERVICE_SETTING_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -58,6 +117,7 @@ SERVICE_SETTING_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_OPTION_SCHEMA = vol.Schema( { vol.Required(ATTR_DEVICE_ID): str, @@ -70,6 +130,7 @@ SERVICE_OPTION_SCHEMA = vol.Schema( } ) +# DEPRECATED: Remove in 2025.9.0 SERVICE_PROGRAM_SCHEMA = vol.Any( { vol.Required(ATTR_DEVICE_ID): str, @@ -93,6 +154,51 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( }, ) + +def _require_program_or_at_least_one_option(data: dict) -> dict: + if ATTR_PROGRAM not in data and not any( + option_key in data + for option_key in ( + PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ) + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="required_program_or_one_option_at_least", + ) + return data + + +SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_AFFECTS_TO): vol.In( + [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM] + ), + vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()), + } + ) + .extend( + { + vol.Optional(translation_key): vol.In(allowed_values.keys()) + for translation_key, ( + key, + allowed_values, + ) in PROGRAM_ENUM_OPTIONS.items() + } + ) + .extend( + { + vol.Optional(translation_key): schema + for translation_key, (key, schema) in ( + PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS + ).items() + } + ), + _require_program_or_at_least_one_option, +) + SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) PLATFORMS = [ @@ -144,7 +250,7 @@ async def _get_client_and_ha_id( return entry.runtime_data.client, ha_id -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up Home Connect component.""" async def _async_service_program(call: ServiceCall, start: bool): @@ -165,6 +271,57 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: else None ) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_PROGRAM}: {program}", + *([f" {ATTR_KEY}: {options[0].key}"] if options else []), + *([f" {ATTR_VALUE}: {options[0].value}"] if options else []), + *( + [f" {ATTR_UNIT}: {options[0].unit}"] + if options and options[0].unit + else [] + ), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}", + f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}", + *( + [ + f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}" + ] + if options + else [] + ), + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) + try: if start: await client.start_program(ha_id, program_key=program, options=options) @@ -189,6 +346,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: unit = call.data.get(ATTR_UNIT) client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) + async_create_issue( + hass, + DOMAIN, + "deprecated_set_program_and_option_actions", + breaks_in_ha_version="2025.9.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_set_program_and_option_actions", + translation_placeholders={ + "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS, + "remove_release": "2025.9.0", + "deprecated_action_yaml": "\n".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_KEY}: {option_key}", + f" {ATTR_VALUE}: {value}", + *([f" {ATTR_UNIT}: {unit}"] if unit else []), + "```", + ] + ), + "new_action_yaml": "\n ".join( + [ + "```yaml", + f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}", + "data:", + f" {ATTR_DEVICE_ID}: DEVICE_ID", + f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}", + f" {bsh_key_to_translation_key(option_key)}: {value}", + "```", + ] + ), + "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)", + }, + ) try: if active: await client.set_active_program_option( @@ -272,6 +467,82 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Service for selecting a program.""" await _async_service_program(call, False) + async def async_service_set_program_and_options(call: ServiceCall): + """Service for setting a program and options.""" + data = dict(call.data) + program = data.pop(ATTR_PROGRAM, None) + affects_to = data.pop(ATTR_AFFECTS_TO) + client, ha_id = await _get_client_and_ha_id(hass, data.pop(ATTR_DEVICE_ID)) + + options: list[Option] = [] + + for option, value in data.items(): + if option in PROGRAM_ENUM_OPTIONS: + options.append( + Option( + PROGRAM_ENUM_OPTIONS[option][0], + PROGRAM_ENUM_OPTIONS[option][1][value], + ) + ) + elif option in PROGRAM_OPTIONS: + option_key = PROGRAM_OPTIONS[option][0] + options.append(Option(option_key, value)) + elif option in TIME_PROGRAM_OPTIONS: + options.append( + Option( + TIME_PROGRAM_OPTIONS[option][0], + int(cast(timedelta, value).total_seconds()), + ) + ) + method_call: Awaitable[Any] + exception_translation_key: str + if program: + program = ( + program + if isinstance(program, ProgramKey) + else TRANSLATION_KEYS_PROGRAMS_MAP[program] + ) + + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.start_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "start_program" + elif affects_to == AFFECTS_TO_SELECTED_PROGRAM: + method_call = client.set_selected_program( + ha_id, program_key=program, options=options + ) + exception_translation_key = "select_program" + else: + array_of_options = ArrayOfOptions(options) + if affects_to == AFFECTS_TO_ACTIVE_PROGRAM: + method_call = client.set_active_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_active_program" + else: + # affects_to is AFFECTS_TO_SELECTED_PROGRAM + method_call = client.set_selected_program_options( + ha_id, array_of_options=array_of_options + ) + exception_translation_key = "set_options_selected_program" + + try: + await method_call + except HomeConnectError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=exception_translation_key, + translation_placeholders={ + **get_dict_from_home_connect_error(err), + **( + {SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program} + if program + else {} + ), + }, + ) from err + async def async_service_start_program(call: ServiceCall): """Service for starting a program.""" await _async_service_program(call, True) @@ -315,6 +586,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_service_start_program, schema=SERVICE_PROGRAM_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_PROGRAM_AND_OPTIONS, + async_service_set_program_and_options, + schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA, + ) return True @@ -349,6 +626,7 @@ async def async_unload_entry( hass: HomeAssistant, entry: HomeConnectConfigEntry ) -> bool: """Unload a config entry.""" + async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 127aa1ffe92..0ec7d3a2629 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -1,6 +1,10 @@ """Constants for the Home Connect integration.""" -from aiohomeconnect.model import EventKey, SettingKey, StatusKey +from typing import cast + +from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey + +from .utils import bsh_key_to_translation_key DOMAIN = "home_connect" @@ -52,15 +56,18 @@ SERVICE_OPTION_SELECTED = "set_option_selected" SERVICE_PAUSE_PROGRAM = "pause_program" SERVICE_RESUME_PROGRAM = "resume_program" SERVICE_SELECT_PROGRAM = "select_program" +SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options" SERVICE_SETTING = "change_setting" SERVICE_START_PROGRAM = "start_program" - +ATTR_AFFECTS_TO = "affects_to" ATTR_KEY = "key" ATTR_PROGRAM = "program" ATTR_UNIT = "unit" ATTR_VALUE = "value" +AFFECTS_TO_ACTIVE_PROGRAM = "active_program" +AFFECTS_TO_SELECTED_PROGRAM = "selected_program" SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity" SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name" @@ -70,6 +77,244 @@ SVE_TRANSLATION_PLACEHOLDER_KEY = "key" SVE_TRANSLATION_PLACEHOLDER_VALUE = "value" +TRANSLATION_KEYS_PROGRAMS_MAP = { + bsh_key_to_translation_key(program.value): cast(ProgramKey, program) + for program in ProgramKey + if program != ProgramKey.UNKNOWN +} + +PROGRAMS_TRANSLATION_KEYS_MAP = { + value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() +} + +REFERENCE_MAP_ID_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2", + "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3", + ) +} + +CLEANING_MODE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard", + "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power", + ) +} + +BEAN_AMOUNT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus", + "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround", + ) +} + +COFFEE_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C", + ) +} + +BEAN_CONTAINER_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right", + "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left", + ) +} + +FLOW_RATE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense", + "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus", + ) +} + +HOT_WATER_TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F", + "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max", + ) +} + +DRYING_TARGET_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Dryer.EnumType.DryingTarget.IronDry", + "LaundryCare.Dryer.EnumType.DryingTarget.GentleDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry", + "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus", + "LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry", + ) +} + +VENTING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.Stage.FanOff", + "Cooking.Hood.EnumType.Stage.FanStage01", + "Cooking.Hood.EnumType.Stage.FanStage02", + "Cooking.Hood.EnumType.Stage.FanStage03", + "Cooking.Hood.EnumType.Stage.FanStage04", + "Cooking.Hood.EnumType.Stage.FanStage05", + ) +} + +INTENSIVE_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1", + "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2", + ) +} + +WARMING_LEVEL_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "Cooking.Oven.EnumType.WarmingLevel.Low", + "Cooking.Oven.EnumType.WarmingLevel.Medium", + "Cooking.Oven.EnumType.WarmingLevel.High", + ) +} + +TEMPERATURE_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.Temperature.Cold", + "LaundryCare.Washer.EnumType.Temperature.GC20", + "LaundryCare.Washer.EnumType.Temperature.GC30", + "LaundryCare.Washer.EnumType.Temperature.GC40", + "LaundryCare.Washer.EnumType.Temperature.GC50", + "LaundryCare.Washer.EnumType.Temperature.GC60", + "LaundryCare.Washer.EnumType.Temperature.GC70", + "LaundryCare.Washer.EnumType.Temperature.GC80", + "LaundryCare.Washer.EnumType.Temperature.GC90", + "LaundryCare.Washer.EnumType.Temperature.UlCold", + "LaundryCare.Washer.EnumType.Temperature.UlWarm", + "LaundryCare.Washer.EnumType.Temperature.UlHot", + "LaundryCare.Washer.EnumType.Temperature.UlExtraHot", + ) +} + +SPIN_SPEED_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Washer.EnumType.SpinSpeed.Off", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM600", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM800", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400", + "LaundryCare.Washer.EnumType.SpinSpeed.RPM1600", + "LaundryCare.Washer.EnumType.SpinSpeed.UlOff", + "LaundryCare.Washer.EnumType.SpinSpeed.UlLow", + "LaundryCare.Washer.EnumType.SpinSpeed.UlMedium", + "LaundryCare.Washer.EnumType.SpinSpeed.UlHigh", + ) +} + +VARIO_PERFECT_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "LaundryCare.Common.EnumType.VarioPerfect.Off", + "LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect", + "LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect", + ) +} + + +PROGRAM_ENUM_OPTIONS = { + bsh_key_to_translation_key(option_key): ( + option_key, + options, + ) + for option_key, options in ( + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, + REFERENCE_MAP_ID_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, + CLEANING_MODE_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE, + COFFEE_TEMPERATURE_OPTIONS, + ), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION, + BEAN_CONTAINER_OPTIONS, + ), + (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, + HOT_WATER_TEMPERATURE_OPTIONS, + ), + (OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS), + (OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS), + (OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS), + (OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS), + (OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS), + ) +} + + OLD_NEW_UNIQUE_ID_SUFFIX_MAP = { "ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK, "Operation State": StatusKey.BSH_COMMON_OPERATION_STATE, diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json index 166b2fe2c34..6b604fc004e 100644 --- a/homeassistant/components/home_connect/icons.json +++ b/homeassistant/components/home_connect/icons.json @@ -18,6 +18,9 @@ "set_option_selected": { "service": "mdi:gesture-tap" }, + "set_program_and_options": { + "service": "mdi:form-select" + }, "change_setting": { "service": "mdi:cog" } diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 94085af2fc3..06325afaed8 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -3,7 +3,7 @@ "name": "Home Connect", "codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"], "config_flow": true, - "dependencies": ["application_credentials"], + "dependencies": ["application_credentials", "repairs"], "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index 165842abf1c..bc281e3d928 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -15,24 +15,20 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import setup_home_connect_entry -from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM +from .const import ( + APPLIANCES_WITH_PROGRAMS, + DOMAIN, + PROGRAMS_TRANSLATION_KEYS_MAP, + SVE_TRANSLATION_PLACEHOLDER_PROGRAM, + TRANSLATION_KEYS_PROGRAMS_MAP, +) from .coordinator import ( HomeConnectApplianceData, HomeConnectConfigEntry, HomeConnectCoordinator, ) from .entity import HomeConnectEntity -from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error - -TRANSLATION_KEYS_PROGRAMS_MAP = { - bsh_key_to_translation_key(program.value): cast(ProgramKey, program) - for program in ProgramKey - if program != ProgramKey.UNKNOWN -} - -PROGRAMS_TRANSLATION_KEYS_MAP = { - value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() -} +from .utils import get_dict_from_home_connect_error @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 0738b58595a..29ca3da15fc 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -46,6 +46,532 @@ select_program: example: "seconds" selector: text: +set_program_and_options: + fields: + device_id: + required: true + selector: + device: + integration: home_connect + affects_to: + example: active_program + required: true + selector: + select: + translation_key: affects_to + options: + - active_program + - selected_program + program: + example: dishcare_dishwasher_program_auto2 + required: true + selector: + select: + mode: dropdown + custom_value: false + translation_key: programs + options: + - consumer_products_cleaning_robot_program_cleaning_clean_all + - consumer_products_cleaning_robot_program_cleaning_clean_map + - consumer_products_cleaning_robot_program_basic_go_home + - consumer_products_coffee_maker_program_beverage_ristretto + - consumer_products_coffee_maker_program_beverage_espresso + - consumer_products_coffee_maker_program_beverage_espresso_doppio + - consumer_products_coffee_maker_program_beverage_coffee + - consumer_products_coffee_maker_program_beverage_x_l_coffee + - consumer_products_coffee_maker_program_beverage_caffe_grande + - consumer_products_coffee_maker_program_beverage_espresso_macchiato + - consumer_products_coffee_maker_program_beverage_cappuccino + - consumer_products_coffee_maker_program_beverage_latte_macchiato + - consumer_products_coffee_maker_program_beverage_caffe_latte + - consumer_products_coffee_maker_program_beverage_milk_froth + - consumer_products_coffee_maker_program_beverage_warm_milk + - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner + - consumer_products_coffee_maker_program_coffee_world_grosser_brauner + - consumer_products_coffee_maker_program_coffee_world_verlaengerter + - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun + - consumer_products_coffee_maker_program_coffee_world_wiener_melange + - consumer_products_coffee_maker_program_coffee_world_flat_white + - consumer_products_coffee_maker_program_coffee_world_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_cortado + - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche + - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait + - consumer_products_coffee_maker_program_coffee_world_doppio + - consumer_products_coffee_maker_program_coffee_world_kaapi + - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd + - consumer_products_coffee_maker_program_coffee_world_galao + - consumer_products_coffee_maker_program_coffee_world_garoto + - consumer_products_coffee_maker_program_coffee_world_americano + - consumer_products_coffee_maker_program_coffee_world_red_eye + - consumer_products_coffee_maker_program_coffee_world_black_eye + - consumer_products_coffee_maker_program_coffee_world_dead_eye + - consumer_products_coffee_maker_program_beverage_hot_water + - dishcare_dishwasher_program_pre_rinse + - dishcare_dishwasher_program_auto_1 + - dishcare_dishwasher_program_auto_2 + - dishcare_dishwasher_program_auto_3 + - dishcare_dishwasher_program_eco_50 + - dishcare_dishwasher_program_quick_45 + - dishcare_dishwasher_program_intensiv_70 + - dishcare_dishwasher_program_normal_65 + - dishcare_dishwasher_program_glas_40 + - dishcare_dishwasher_program_glass_care + - dishcare_dishwasher_program_night_wash + - dishcare_dishwasher_program_quick_65 + - dishcare_dishwasher_program_normal_45 + - dishcare_dishwasher_program_intensiv_45 + - dishcare_dishwasher_program_auto_half_load + - dishcare_dishwasher_program_intensiv_power + - dishcare_dishwasher_program_magic_daily + - dishcare_dishwasher_program_super_60 + - dishcare_dishwasher_program_kurz_60 + - dishcare_dishwasher_program_express_sparkle_65 + - dishcare_dishwasher_program_machine_care + - dishcare_dishwasher_program_steam_fresh + - dishcare_dishwasher_program_maximum_cleaning + - dishcare_dishwasher_program_mixed_load + - laundry_care_dryer_program_cotton + - laundry_care_dryer_program_synthetic + - laundry_care_dryer_program_mix + - laundry_care_dryer_program_blankets + - laundry_care_dryer_program_business_shirts + - laundry_care_dryer_program_down_feathers + - laundry_care_dryer_program_hygiene + - laundry_care_dryer_program_jeans + - laundry_care_dryer_program_outdoor + - laundry_care_dryer_program_synthetic_refresh + - laundry_care_dryer_program_towels + - laundry_care_dryer_program_delicates + - laundry_care_dryer_program_super_40 + - laundry_care_dryer_program_shirts_15 + - laundry_care_dryer_program_pillow + - laundry_care_dryer_program_anti_shrink + - laundry_care_dryer_program_my_time_my_drying_time + - laundry_care_dryer_program_time_cold + - laundry_care_dryer_program_time_warm + - laundry_care_dryer_program_in_basket + - laundry_care_dryer_program_time_cold_fix_time_cold_20 + - laundry_care_dryer_program_time_cold_fix_time_cold_30 + - laundry_care_dryer_program_time_cold_fix_time_cold_60 + - laundry_care_dryer_program_time_warm_fix_time_warm_30 + - laundry_care_dryer_program_time_warm_fix_time_warm_40 + - laundry_care_dryer_program_time_warm_fix_time_warm_60 + - laundry_care_dryer_program_dessous + - cooking_common_program_hood_automatic + - cooking_common_program_hood_venting + - cooking_common_program_hood_delayed_shut_off + - cooking_oven_program_heating_mode_pre_heating + - cooking_oven_program_heating_mode_hot_air + - cooking_oven_program_heating_mode_hot_air_eco + - cooking_oven_program_heating_mode_hot_air_grilling + - cooking_oven_program_heating_mode_top_bottom_heating + - cooking_oven_program_heating_mode_top_bottom_heating_eco + - cooking_oven_program_heating_mode_bottom_heating + - cooking_oven_program_heating_mode_pizza_setting + - cooking_oven_program_heating_mode_slow_cook + - cooking_oven_program_heating_mode_intensive_heat + - cooking_oven_program_heating_mode_keep_warm + - cooking_oven_program_heating_mode_preheat_ovenware + - cooking_oven_program_heating_mode_frozen_heatup_special + - cooking_oven_program_heating_mode_desiccation + - cooking_oven_program_heating_mode_defrost + - cooking_oven_program_heating_mode_proof + - cooking_oven_program_heating_mode_hot_air_30_steam + - cooking_oven_program_heating_mode_hot_air_60_steam + - cooking_oven_program_heating_mode_hot_air_80_steam + - cooking_oven_program_heating_mode_hot_air_100_steam + - cooking_oven_program_heating_mode_sabbath_programme + - cooking_oven_program_microwave_90_watt + - cooking_oven_program_microwave_180_watt + - cooking_oven_program_microwave_360_watt + - cooking_oven_program_microwave_600_watt + - cooking_oven_program_microwave_900_watt + - cooking_oven_program_microwave_1000_watt + - cooking_oven_program_microwave_max + - cooking_oven_program_heating_mode_warming_drawer + - laundry_care_washer_program_cotton + - laundry_care_washer_program_cotton_cotton_eco + - laundry_care_washer_program_cotton_eco_4060 + - laundry_care_washer_program_cotton_colour + - laundry_care_washer_program_easy_care + - laundry_care_washer_program_mix + - laundry_care_washer_program_mix_night_wash + - laundry_care_washer_program_delicates_silk + - laundry_care_washer_program_wool + - laundry_care_washer_program_sensitive + - laundry_care_washer_program_auto_30 + - laundry_care_washer_program_auto_40 + - laundry_care_washer_program_auto_60 + - laundry_care_washer_program_chiffon + - laundry_care_washer_program_curtains + - laundry_care_washer_program_dark_wash + - laundry_care_washer_program_dessous + - laundry_care_washer_program_monsoon + - laundry_care_washer_program_outdoor + - laundry_care_washer_program_plush_toy + - laundry_care_washer_program_shirts_blouses + - laundry_care_washer_program_sport_fitness + - laundry_care_washer_program_towels + - laundry_care_washer_program_water_proof + - laundry_care_washer_program_power_speed_59 + - laundry_care_washer_program_super_153045_super_15 + - laundry_care_washer_program_super_153045_super_1530 + - laundry_care_washer_program_down_duvet_duvet + - laundry_care_washer_program_rinse_rinse_spin_drain + - laundry_care_washer_program_drum_clean + - laundry_care_washer_dryer_program_cotton + - laundry_care_washer_dryer_program_cotton_eco_4060 + - laundry_care_washer_dryer_program_mix + - laundry_care_washer_dryer_program_easy_care + - laundry_care_washer_dryer_program_wash_and_dry_60 + - laundry_care_washer_dryer_program_wash_and_dry_90 + cleaning_robot_options: + collapsed: true + fields: + consumer_products_cleaning_robot_option_reference_map_id: + example: consumer_products_cleaning_robot_enum_type_available_maps_map1 + required: false + selector: + select: + mode: dropdown + translation_key: available_maps + options: + - consumer_products_cleaning_robot_enum_type_available_maps_temp_map + - consumer_products_cleaning_robot_enum_type_available_maps_map1 + - consumer_products_cleaning_robot_enum_type_available_maps_map2 + - consumer_products_cleaning_robot_enum_type_available_maps_map3 + consumer_products_cleaning_robot_option_cleaning_mode: + example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + required: false + selector: + select: + mode: dropdown + translation_key: cleaning_mode + options: + - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent + - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard + - consumer_products_cleaning_robot_enum_type_cleaning_modes_power + coffee_maker_options: + collapsed: true + fields: + consumer_products_coffee_maker_option_bean_amount: + example: consumer_products_coffee_maker_enum_type_bean_amount_normal + required: false + selector: + select: + mode: dropdown + translation_key: bean_amount + options: + - consumer_products_coffee_maker_enum_type_bean_amount_very_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild + - consumer_products_coffee_maker_enum_type_bean_amount_mild_plus + - consumer_products_coffee_maker_enum_type_bean_amount_normal + - consumer_products_coffee_maker_enum_type_bean_amount_normal_plus + - consumer_products_coffee_maker_enum_type_bean_amount_strong + - consumer_products_coffee_maker_enum_type_bean_amount_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong + - consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus + - consumer_products_coffee_maker_enum_type_bean_amount_extra_strong + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot + - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus + - consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground + consumer_products_coffee_maker_option_fill_quantity: + example: 60 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: ml + consumer_products_coffee_maker_option_coffee_temperature: + example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + required: false + selector: + select: + mode: dropdown + translation_key: coffee_temperature + options: + - consumer_products_coffee_maker_enum_type_coffee_temperature_88_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_90_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_92_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c + - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c + consumer_products_coffee_maker_option_bean_container: + example: consumer_products_coffee_maker_enum_type_bean_container_selection_right + required: false + selector: + select: + mode: dropdown + translation_key: bean_container + options: + - consumer_products_coffee_maker_enum_type_bean_container_selection_right + - consumer_products_coffee_maker_enum_type_bean_container_selection_left + consumer_products_coffee_maker_option_flow_rate: + example: consumer_products_coffee_maker_enum_type_flow_rate_normal + required: false + selector: + select: + mode: dropdown + translation_key: flow_rate + options: + - consumer_products_coffee_maker_enum_type_flow_rate_normal + - consumer_products_coffee_maker_enum_type_flow_rate_intense + - consumer_products_coffee_maker_enum_type_flow_rate_intense_plus + consumer_products_coffee_maker_option_multiple_beverages: + example: false + required: false + selector: + boolean: + consumer_products_coffee_maker_option_coffee_milk_ratio: + example: 50 + required: false + selector: + number: + unit_of_measurement: "%" + step: 10 + min: 10 + max: 90 + consumer_products_coffee_maker_option_hot_water_temperature: + example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + required: false + selector: + select: + mode: dropdown + translation_key: hot_water_temperature + options: + - consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea + - consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c + - consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f + - consumer_products_coffee_maker_enum_type_hot_water_temperature_max + dish_washer_options: + collapsed: true + fields: + b_s_h_common_option_start_in_relative: + example: "30:00" + required: false + selector: + time: + dishcare_dishwasher_option_intensiv_zone: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_brilliance_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_vario_speed_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_silence_on_demand: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_half_load: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_extra_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_hygiene_plus: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_eco_dry: + example: false + required: false + selector: + boolean: + dishcare_dishwasher_option_zeolite_dry: + example: false + required: false + selector: + boolean: + dryer_options: + collapsed: true + fields: + laundry_care_dryer_option_drying_target: + example: laundry_care_dryer_enum_type_drying_target_iron_dry + required: false + selector: + select: + mode: dropdown + translation_key: drying_target + options: + - laundry_care_dryer_enum_type_drying_target_iron_dry + - laundry_care_dryer_enum_type_drying_target_gentle_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry + - laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus + - laundry_care_dryer_enum_type_drying_target_extra_dry + hood_options: + collapsed: true + fields: + cooking_hood_option_venting_level: + example: cooking_hood_enum_type_stage_fan_stage01 + required: false + selector: + select: + mode: dropdown + translation_key: venting_level + options: + - cooking_hood_enum_type_stage_fan_off + - cooking_hood_enum_type_stage_fan_stage01 + - cooking_hood_enum_type_stage_fan_stage02 + - cooking_hood_enum_type_stage_fan_stage03 + - cooking_hood_enum_type_stage_fan_stage04 + - cooking_hood_enum_type_stage_fan_stage05 + cooking_hood_option_intensive_level: + example: cooking_hood_enum_type_intensive_stage_intensive_stage1 + required: false + selector: + select: + mode: dropdown + translation_key: intensive_level + options: + - cooking_hood_enum_type_intensive_stage_intensive_stage_off + - cooking_hood_enum_type_intensive_stage_intensive_stage1 + - cooking_hood_enum_type_intensive_stage_intensive_stage2 + oven_options: + collapsed: true + fields: + cooking_oven_option_setpoint_temperature: + example: 180 + required: false + selector: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: °C/°F + b_s_h_common_option_duration: + example: "30:00" + required: false + selector: + time: + cooking_oven_option_fast_pre_heat: + example: false + required: false + selector: + boolean: + warming_drawer_options: + collapsed: true + fields: + cooking_oven_option_warming_level: + example: cooking_oven_enum_type_warming_level_medium + required: false + selector: + select: + mode: dropdown + translation_key: warming_level + options: + - cooking_oven_enum_type_warming_level_low + - cooking_oven_enum_type_warming_level_medium + - cooking_oven_enum_type_warming_level_high + washer_options: + collapsed: true + fields: + laundry_care_washer_option_temperature: + example: laundry_care_washer_enum_type_temperature_g_c40 + required: false + selector: + select: + mode: dropdown + translation_key: washer_temperature + options: + - laundry_care_washer_enum_type_temperature_cold + - laundry_care_washer_enum_type_temperature_g_c20 + - laundry_care_washer_enum_type_temperature_g_c30 + - laundry_care_washer_enum_type_temperature_g_c40 + - laundry_care_washer_enum_type_temperature_g_c50 + - laundry_care_washer_enum_type_temperature_g_c60 + - laundry_care_washer_enum_type_temperature_g_c70 + - laundry_care_washer_enum_type_temperature_g_c80 + - laundry_care_washer_enum_type_temperature_g_c90 + - laundry_care_washer_enum_type_temperature_ul_cold + - laundry_care_washer_enum_type_temperature_ul_warm + - laundry_care_washer_enum_type_temperature_ul_hot + - laundry_care_washer_enum_type_temperature_ul_extra_hot + laundry_care_washer_option_spin_speed: + example: laundry_care_washer_enum_type_spin_speed_r_p_m800 + required: false + selector: + select: + mode: dropdown + translation_key: spin_speed + options: + - laundry_care_washer_enum_type_spin_speed_off + - laundry_care_washer_enum_type_spin_speed_r_p_m400 + - laundry_care_washer_enum_type_spin_speed_r_p_m600 + - laundry_care_washer_enum_type_spin_speed_r_p_m800 + - laundry_care_washer_enum_type_spin_speed_r_p_m1000 + - laundry_care_washer_enum_type_spin_speed_r_p_m1200 + - laundry_care_washer_enum_type_spin_speed_r_p_m1400 + - laundry_care_washer_enum_type_spin_speed_r_p_m1600 + - laundry_care_washer_enum_type_spin_speed_ul_off + - laundry_care_washer_enum_type_spin_speed_ul_low + - laundry_care_washer_enum_type_spin_speed_ul_medium + - laundry_care_washer_enum_type_spin_speed_ul_high + b_s_h_common_option_finish_in_relative: + example: "30:00" + required: false + selector: + time: + laundry_care_washer_option_i_dos1_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_i_dos2_active: + example: false + required: false + selector: + boolean: + laundry_care_washer_option_vario_perfect: + example: laundry_care_common_enum_type_vario_perfect_eco_perfect + required: false + selector: + select: + mode: dropdown + translation_key: vario_perfect + options: + - laundry_care_common_enum_type_vario_perfect_off + - laundry_care_common_enum_type_vario_perfect_eco_perfect + - laundry_care_common_enum_type_vario_perfect_speed_perfect pause_program: fields: device_id: diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index d07cfcdf854..38fdd6f6ec3 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -95,6 +95,9 @@ }, "fetch_api_error": { "message": "Error obtaining data from the API: {error}" + }, + "required_program_or_one_option_at_least": { + "message": "A program or at least one of the possible options for a program should be specified" } }, "issues": { @@ -105,6 +108,343 @@ "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + }, + "deprecated_set_program_and_option_actions": { + "title": "The executed action is deprecated", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]", + "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}." + } + } + } + } + }, + "selector": { + "affects_to": { + "options": { + "active_program": "Active program", + "selected_program": "Selected program" + } + }, + "programs": { + "options": { + "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", + "consumer_products_cleaning_robot_program_basic_go_home": "Go home", + "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", + "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", + "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", + "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", + "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", + "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", + "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", + "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", + "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", + "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", + "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", + "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", + "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", + "dishcare_dishwasher_program_auto_1": "Auto 1", + "dishcare_dishwasher_program_auto_2": "Auto 2", + "dishcare_dishwasher_program_auto_3": "Auto 3", + "dishcare_dishwasher_program_eco_50": "Eco 50ºC", + "dishcare_dishwasher_program_quick_45": "Quick 45ºC", + "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", + "dishcare_dishwasher_program_normal_65": "Normal 65ºC", + "dishcare_dishwasher_program_glas_40": "Glass 40ºC", + "dishcare_dishwasher_program_glass_care": "Glass care", + "dishcare_dishwasher_program_night_wash": "Night wash", + "dishcare_dishwasher_program_quick_65": "Quick 65ºC", + "dishcare_dishwasher_program_normal_45": "Normal 45ºC", + "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", + "dishcare_dishwasher_program_auto_half_load": "Auto half load", + "dishcare_dishwasher_program_intensiv_power": "Intensive power", + "dishcare_dishwasher_program_magic_daily": "Magic daily", + "dishcare_dishwasher_program_super_60": "Super 60ºC", + "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", + "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", + "dishcare_dishwasher_program_machine_care": "Machine care", + "dishcare_dishwasher_program_steam_fresh": "Steam fresh", + "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", + "dishcare_dishwasher_program_mixed_load": "Mixed load", + "laundry_care_dryer_program_cotton": "Cotton", + "laundry_care_dryer_program_synthetic": "Synthetic", + "laundry_care_dryer_program_mix": "Mix", + "laundry_care_dryer_program_blankets": "Blankets", + "laundry_care_dryer_program_business_shirts": "Business shirts", + "laundry_care_dryer_program_down_feathers": "Down feathers", + "laundry_care_dryer_program_hygiene": "Hygiene", + "laundry_care_dryer_program_jeans": "Jeans", + "laundry_care_dryer_program_outdoor": "Outdoor", + "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", + "laundry_care_dryer_program_towels": "Towels", + "laundry_care_dryer_program_delicates": "Delicates", + "laundry_care_dryer_program_super_40": "Super 40ºC", + "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", + "laundry_care_dryer_program_pillow": "Pillow", + "laundry_care_dryer_program_anti_shrink": "Anti shrink", + "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", + "laundry_care_dryer_program_time_cold": "Cold (variable time)", + "laundry_care_dryer_program_time_warm": "Warm (variable time)", + "laundry_care_dryer_program_in_basket": "In basket", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", + "laundry_care_dryer_program_dessous": "Dessous", + "cooking_common_program_hood_automatic": "Automatic", + "cooking_common_program_hood_venting": "Venting", + "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", + "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", + "cooking_oven_program_heating_mode_hot_air": "Hot air", + "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", + "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", + "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", + "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", + "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", + "cooking_oven_program_heating_mode_slow_cook": "Slow cook", + "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", + "cooking_oven_program_heating_mode_keep_warm": "Keep warm", + "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", + "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", + "cooking_oven_program_heating_mode_desiccation": "Desiccation", + "cooking_oven_program_heating_mode_defrost": "Defrost", + "cooking_oven_program_heating_mode_proof": "Proof", + "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", + "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", + "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", + "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", + "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", + "cooking_oven_program_microwave_90_watt": "90 Watt", + "cooking_oven_program_microwave_180_watt": "180 Watt", + "cooking_oven_program_microwave_360_watt": "360 Watt", + "cooking_oven_program_microwave_600_watt": "600 Watt", + "cooking_oven_program_microwave_900_watt": "900 Watt", + "cooking_oven_program_microwave_1000_watt": "1000 Watt", + "cooking_oven_program_microwave_max": "Max", + "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", + "laundry_care_washer_program_cotton": "Cotton", + "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", + "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_program_cotton_colour": "Cotton color", + "laundry_care_washer_program_easy_care": "Easy care", + "laundry_care_washer_program_mix": "Mix", + "laundry_care_washer_program_mix_night_wash": "Mix night wash", + "laundry_care_washer_program_delicates_silk": "Delicates silk", + "laundry_care_washer_program_wool": "Wool", + "laundry_care_washer_program_sensitive": "Sensitive", + "laundry_care_washer_program_auto_30": "Auto 30ºC", + "laundry_care_washer_program_auto_40": "Auto 40ºC", + "laundry_care_washer_program_auto_60": "Auto 60ºC", + "laundry_care_washer_program_chiffon": "Chiffon", + "laundry_care_washer_program_curtains": "Curtains", + "laundry_care_washer_program_dark_wash": "Dark wash", + "laundry_care_washer_program_dessous": "Dessous", + "laundry_care_washer_program_monsoon": "Monsoon", + "laundry_care_washer_program_outdoor": "Outdoor", + "laundry_care_washer_program_plush_toy": "Plush toy", + "laundry_care_washer_program_shirts_blouses": "Shirts blouses", + "laundry_care_washer_program_sport_fitness": "Sport fitness", + "laundry_care_washer_program_towels": "Towels", + "laundry_care_washer_program_water_proof": "Water proof", + "laundry_care_washer_program_power_speed_59": "Power speed <59 min", + "laundry_care_washer_program_super_153045_super_15": "Super 15 min", + "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", + "laundry_care_washer_program_down_duvet_duvet": "Down duvet", + "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", + "laundry_care_washer_program_drum_clean": "Drum clean", + "laundry_care_washer_dryer_program_cotton": "Cotton", + "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60ºC", + "laundry_care_washer_dryer_program_mix": "Mix", + "laundry_care_washer_dryer_program_easy_care": "Easy care", + "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", + "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + } + }, + "available_maps": { + "options": { + "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map", + "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1", + "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2", + "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3" + } + }, + "cleaning_mode": { + "options": { + "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard", + "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power" + } + }, + "bean_amount": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "Very mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild": "Mild", + "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "Mild +", + "consumer_products_coffee_maker_enum_type_bean_amount_normal": "Normal", + "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "Normal +", + "consumer_products_coffee_maker_enum_type_bean_amount_strong": "Strong", + "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "Strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "Very strong", + "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "Very strong +", + "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "Extra strong", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "Double shot", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "Double shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "Double shot ++", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "Triple shot", + "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "Triple shot +", + "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "Coffee ground" + } + }, + "coffee_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "88ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "92ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "94ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "96ºC" + } + }, + "bean_container": { + "options": { + "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "Right", + "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "Left" + } + }, + "flow_rate": { + "options": { + "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal", + "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense", + "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" + } + }, + "hot_water_temperature": { + "options": { + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "65ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "70ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "75ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "80ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "85ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "90ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "95ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "97ºC", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "122ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "131ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "140ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "149ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "158ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "167ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "176ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "185ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "194ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "203ºF", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "Max" + } + }, + "drying_target": { + "options": { + "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry", + "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry", + "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry plus", + "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry" + } + }, + "venting_level": { + "options": { + "cooking_hood_enum_type_stage_fan_off": "Fan off", + "cooking_hood_enum_type_stage_fan_stage01": "Fan stage 1", + "cooking_hood_enum_type_stage_fan_stage02": "Fan stage 2", + "cooking_hood_enum_type_stage_fan_stage03": "Fan stage 3", + "cooking_hood_enum_type_stage_fan_stage04": "Fan stage 4", + "cooking_hood_enum_type_stage_fan_stage05": "Fan stage 5" + } + }, + "intensive_level": { + "options": { + "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off", + "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1", + "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2" + } + }, + "warming_level": { + "options": { + "cooking_oven_enum_type_warming_level_low": "Low", + "cooking_oven_enum_type_warming_level_medium": "Medium", + "cooking_oven_enum_type_warming_level_high": "High" + } + }, + "washer_temperature": { + "options": { + "laundry_care_washer_enum_type_temperature_cold": "Cold", + "laundry_care_washer_enum_type_temperature_g_c20": "20ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c30": "30ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c40": "40ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c50": "50ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c60": "60ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c70": "70ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c80": "80ºC clothes", + "laundry_care_washer_enum_type_temperature_g_c90": "90ºC clothes", + "laundry_care_washer_enum_type_temperature_ul_cold": "Cold", + "laundry_care_washer_enum_type_temperature_ul_warm": "Warm", + "laundry_care_washer_enum_type_temperature_ul_hot": "Hot", + "laundry_care_washer_enum_type_temperature_ul_extra_hot": "Extra hot" + } + }, + "spin_speed": { + "options": { + "laundry_care_washer_enum_type_spin_speed_off": "Off", + "laundry_care_washer_enum_type_spin_speed_r_p_m400": "400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m600": "600 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m800": "800 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1000": "1000 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1200": "1200 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1400": "1400 rpm", + "laundry_care_washer_enum_type_spin_speed_r_p_m1600": "1600 rpm", + "laundry_care_washer_enum_type_spin_speed_ul_off": "Off", + "laundry_care_washer_enum_type_spin_speed_ul_low": "Low", + "laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium", + "laundry_care_washer_enum_type_spin_speed_ul_high": "High" + } + }, + "vario_perfect": { + "options": { + "laundry_care_common_enum_type_vario_perfect_off": "Off", + "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect", + "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect" + } } }, "services": { @@ -113,8 +453,8 @@ "description": "Selects a program and starts it.", "fields": { "device_id": { - "name": "Device ID", - "description": "ID of the device." + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "Program", "description": "Program to select." }, "key": { "name": "Option key", "description": "Key of the option." }, @@ -130,8 +470,8 @@ "description": "Selects a program without starting it.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "program": { "name": "[%key:component::home_connect::services::start_program::fields::program::name%]", @@ -151,13 +491,197 @@ } } }, + "set_program_and_options": { + "name": "Set program and options", + "description": "Starts or selects a program with options or sets the options for the active or the selected program.", + "fields": { + "device_id": { + "name": "Device ID", + "description": "ID of the device." + }, + "affects_to": { + "name": "Affects to", + "description": "Selects if the program affected by the action should be the active or the selected program." + }, + "program": { + "name": "Program", + "description": "Program to select" + }, + "consumer_products_cleaning_robot_option_reference_map_id": { + "name": "Reference map ID", + "description": "Defines the used reference map." + }, + "consumer_products_cleaning_robot_option_cleaning_mode": { + "name": "Cleaning mode", + "description": "Defines the favoured cleaning mode." + }, + "consumer_products_coffee_maker_option_bean_amount": { + "name": "Bean amount", + "description": "Describes the bean amount of a coffee machine program." + }, + "consumer_products_coffee_maker_option_fill_quantity": { + "name": "Fill quantity", + "description": "Describes the fill quantity (in ml) of a coffee machine program." + }, + "consumer_products_coffee_maker_option_coffee_temperature": { + "name": "Coffee Temperature", + "description": "Describes the coffee temperature of a coffee machine program." + }, + "consumer_products_coffee_maker_option_bean_container": { + "name": "Bean container", + "description": "Defines the preferred bean container." + }, + "consumer_products_coffee_maker_option_flow_rate": { + "name": "Flow rate", + "description": "Defines the water-coffee contact time. The duration extends to coffee intensity." + }, + "consumer_products_coffee_maker_option_multiple_beverages": { + "name": "Multiple beverages", + "description": "Defines if double dispensing is enabled." + }, + "consumer_products_coffee_maker_option_coffee_milk_ratio": { + "name": "Coffee milk ratio", + "description": "Defines the milk amount." + }, + "consumer_products_coffee_maker_option_hot_water_temperature": { + "name": "Hot water temperature", + "description": "Defines the temperature suitable for the type of tea." + }, + "b_s_h_common_option_start_in_relative": { + "name": "Start in relative", + "description": "Defines in how many time the program should start." + }, + "dishcare_dishwasher_option_intensiv_zone": { + "name": "Intensive zone", + "description": "Defines if the cleaning is done with higher spray pressure on the lower basket for very dirty pots and pans." + }, + "dishcare_dishwasher_option_brilliance_dry": { + "name": "Brilliance dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + }, + "dishcare_dishwasher_option_vario_speed_plus": { + "name": "Vario speed plus", + "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying." + }, + "dishcare_dishwasher_option_silence_on_demand": { + "name": "Silence on demand", + "description": "Defines if the extra silent mode is activated for a selected period of time." + }, + "dishcare_dishwasher_option_half_load": { + "name": "Half load", + "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + }, + "dishcare_dishwasher_option_extra_dry": { + "name": "Extra dry", + "description": "Defines if improved drying for glasses and plasticware is enabled." + }, + "dishcare_dishwasher_option_hygiene_plus": { + "name": "Hygiene plus", + "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + }, + "dishcare_dishwasher_option_eco_dry": { + "name": "Eco dry", + "description": "Defines if the door is opened automatically for extra energy efficient and effective drying." + }, + "dishcare_dishwasher_option_zeolite_dry": { + "name": "Zeolite dry", + "description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware." + }, + "laundry_care_dryer_option_drying_target": { + "name": "Drying target", + "description": "Describes the drying target for a dryer program." + }, + "cooking_hood_option_venting_level": { + "name": "Venting level", + "description": "Defines the required fan setting." + }, + "cooking_hood_option_intensive_level": { + "name": "Intensive level", + "description": "Defines the intensive setting." + }, + "cooking_oven_option_setpoint_temperature": { + "name": "Setpoint temperature", + "description": "Defines the target cavity temperature, which will be hold by the oven." + }, + "b_s_h_common_option_duration": { + "name": "Duration", + "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + }, + "cooking_oven_option_fast_pre_heat": { + "name": "Fast pre-heat", + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + }, + "cooking_oven_option_warming_level": { + "name": "Warming level", + "description": "Defines the level of the warming drawer." + }, + "laundry_care_washer_option_temperature": { + "name": "Temperature", + "description": "Defines the temperature of the washing program." + }, + "laundry_care_washer_option_spin_speed": { + "name": "Spin speed", + "description": "Defines the spin speed of a washer program." + }, + "b_s_h_common_option_finish_in_relative": { + "name": "Finish in relative", + "description": "Defines when the program should end in seconds." + }, + "laundry_care_washer_option_i_dos1_active": { + "name": "i-Dos 1 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)" + }, + "laundry_care_washer_option_i_dos2_active": { + "name": "i-Dos 2 Active", + "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)" + }, + "laundry_care_washer_option_vario_perfect": { + "name": "Vario perfect", + "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect)." + } + }, + "sections": { + "cleaning_robot_options": { + "name": "Cleaning robot options", + "description": "Options for cleaning robots." + }, + "coffee_maker_options": { + "name": "Coffee maker options", + "description": "Options for coffee makers." + }, + "dish_washer_options": { + "name": "Dishwasher options", + "description": "Options for dishwashers." + }, + "dryer_options": { + "name": "Dryer options", + "description": "Options for dryers (and washer dryers)." + }, + "hood_options": { + "name": "Hood options", + "description": "Options for hoods." + }, + "oven_options": { + "name": "Oven options", + "description": "Options for ovens." + }, + "warming_drawer_options": { + "name": "Warming drawer options", + "description": "Options for warming drawers." + }, + "washer_options": { + "name": "Washer options", + "description": "Options for washers (and washer dryers)." + } + } + }, "pause_program": { "name": "Pause program", "description": "Pauses the current running program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -166,8 +690,8 @@ "description": "Resumes a paused program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" } } }, @@ -176,8 +700,8 @@ "description": "Sets an option for the active program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", @@ -191,18 +715,18 @@ }, "set_option_selected": { "name": "Set selected program option", - "description": "Sets an option for the selected program.", + "description": "Sets options for the selected program.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { - "name": "Key", + "name": "[%key:component::home_connect::services::start_program::fields::key::name%]", "description": "[%key:component::home_connect::services::start_program::fields::key::description%]" }, "value": { - "name": "Value", + "name": "[%key:component::home_connect::services::start_program::fields::value::name%]", "description": "[%key:component::home_connect::services::start_program::fields::value::description%]" } } @@ -212,8 +736,8 @@ "description": "Changes a setting.", "fields": { "device_id": { - "name": "Device ID", - "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]" + "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]", + "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]" }, "key": { "name": "Key", "description": "Key of the setting." }, "value": { "name": "Value", "description": "Value of the setting." } @@ -307,319 +831,319 @@ "selected_program": { "name": "Selected program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map", - "consumer_products_cleaning_robot_program_basic_go_home": "Go home", - "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto", - "consumer_products_coffee_maker_program_beverage_espresso": "Espresso", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio", - "consumer_products_coffee_maker_program_beverage_coffee": "Coffee", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato", - "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte", - "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth", - "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white", - "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait", - "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd", - "consumer_products_coffee_maker_program_coffee_world_galao": "Galao", - "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto", - "consumer_products_coffee_maker_program_coffee_world_americano": "Americano", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye", - "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water", - "dishcare_dishwasher_program_pre_rinse": "Pre_rinse", - "dishcare_dishwasher_program_auto_1": "Auto 1", - "dishcare_dishwasher_program_auto_2": "Auto 2", - "dishcare_dishwasher_program_auto_3": "Auto 3", - "dishcare_dishwasher_program_eco_50": "Eco 50ºC", - "dishcare_dishwasher_program_quick_45": "Quick 45ºC", - "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC", - "dishcare_dishwasher_program_normal_65": "Normal 65ºC", - "dishcare_dishwasher_program_glas_40": "Glass 40ºC", - "dishcare_dishwasher_program_glass_care": "Glass care", - "dishcare_dishwasher_program_night_wash": "Night wash", - "dishcare_dishwasher_program_quick_65": "Quick 65ºC", - "dishcare_dishwasher_program_normal_45": "Normal 45ºC", - "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC", - "dishcare_dishwasher_program_auto_half_load": "Auto half load", - "dishcare_dishwasher_program_intensiv_power": "Intensive power", - "dishcare_dishwasher_program_magic_daily": "Magic daily", - "dishcare_dishwasher_program_super_60": "Super 60ºC", - "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC", - "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC", - "dishcare_dishwasher_program_machine_care": "Machine care", - "dishcare_dishwasher_program_steam_fresh": "Steam fresh", - "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning", - "dishcare_dishwasher_program_mixed_load": "Mixed load", - "laundry_care_dryer_program_cotton": "Cotton", - "laundry_care_dryer_program_synthetic": "Synthetic", - "laundry_care_dryer_program_mix": "Mix", - "laundry_care_dryer_program_blankets": "Blankets", - "laundry_care_dryer_program_business_shirts": "Business shirts", - "laundry_care_dryer_program_down_feathers": "Down feathers", - "laundry_care_dryer_program_hygiene": "Hygiene", - "laundry_care_dryer_program_jeans": "Jeans", - "laundry_care_dryer_program_outdoor": "Outdoor", - "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh", - "laundry_care_dryer_program_towels": "Towels", - "laundry_care_dryer_program_delicates": "Delicates", - "laundry_care_dryer_program_super_40": "Super 40ºC", - "laundry_care_dryer_program_shirts_15": "Shirts 15ºC", - "laundry_care_dryer_program_pillow": "Pillow", - "laundry_care_dryer_program_anti_shrink": "Anti shrink", - "laundry_care_dryer_program_my_time_my_drying_time": "My drying time", - "laundry_care_dryer_program_time_cold": "Cold (variable time)", - "laundry_care_dryer_program_time_warm": "Warm (variable time)", - "laundry_care_dryer_program_in_basket": "In basket", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)", - "laundry_care_dryer_program_dessous": "Dessous", - "cooking_common_program_hood_automatic": "Automatic", - "cooking_common_program_hood_venting": "Venting", - "cooking_common_program_hood_delayed_shut_off": "Delayed shut off", - "cooking_oven_program_heating_mode_pre_heating": "Pre-heating", - "cooking_oven_program_heating_mode_hot_air": "Hot air", - "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco", - "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling", - "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco", - "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating", - "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting", - "cooking_oven_program_heating_mode_slow_cook": "Slow cook", - "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat", - "cooking_oven_program_heating_mode_keep_warm": "Keep warm", - "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware", - "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products", - "cooking_oven_program_heating_mode_desiccation": "Desiccation", - "cooking_oven_program_heating_mode_defrost": "Defrost", - "cooking_oven_program_heating_mode_proof": "Proof", - "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH", - "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH", - "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH", - "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH", - "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme", - "cooking_oven_program_microwave_90_watt": "90 Watt", - "cooking_oven_program_microwave_180_watt": "180 Watt", - "cooking_oven_program_microwave_360_watt": "360 Watt", - "cooking_oven_program_microwave_600_watt": "600 Watt", - "cooking_oven_program_microwave_900_watt": "900 Watt", - "cooking_oven_program_microwave_1000_watt": "1000 Watt", - "cooking_oven_program_microwave_max": "Max", - "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer", - "laundry_care_washer_program_cotton": "Cotton", - "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco", - "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC", - "laundry_care_washer_program_cotton_colour": "Cotton color", - "laundry_care_washer_program_easy_care": "Easy care", - "laundry_care_washer_program_mix": "Mix", - "laundry_care_washer_program_mix_night_wash": "Mix night wash", - "laundry_care_washer_program_delicates_silk": "Delicates silk", - "laundry_care_washer_program_wool": "Wool", - "laundry_care_washer_program_sensitive": "Sensitive", - "laundry_care_washer_program_auto_30": "Auto 30ºC", - "laundry_care_washer_program_auto_40": "Auto 40ºC", - "laundry_care_washer_program_auto_60": "Auto 60ºC", - "laundry_care_washer_program_chiffon": "Chiffon", - "laundry_care_washer_program_curtains": "Curtains", - "laundry_care_washer_program_dark_wash": "Dark wash", - "laundry_care_washer_program_dessous": "Dessous", - "laundry_care_washer_program_monsoon": "Monsoon", - "laundry_care_washer_program_outdoor": "Outdoor", - "laundry_care_washer_program_plush_toy": "Plush toy", - "laundry_care_washer_program_shirts_blouses": "Shirts blouses", - "laundry_care_washer_program_sport_fitness": "Sport fitness", - "laundry_care_washer_program_towels": "Towels", - "laundry_care_washer_program_water_proof": "Water proof", - "laundry_care_washer_program_power_speed_59": "Power speed <60 min", - "laundry_care_washer_program_super_153045_super_15": "Super 15 min", - "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min", - "laundry_care_washer_program_down_duvet_duvet": "Down duvet", - "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain", - "laundry_care_washer_program_drum_clean": "Drum clean", - "laundry_care_washer_dryer_program_cotton": "Cotton", - "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC", - "laundry_care_washer_dryer_program_mix": "Mix", - "laundry_care_washer_dryer_program_easy_care": "Easy care", - "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)", - "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } }, "active_program": { "name": "Active program", "state": { - "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]", - "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]", - "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]", - "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]", - "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]", - "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", - "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]", - "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", - "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]", - "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", - "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]", - "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", - "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]", - "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]", - "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]", - "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", - "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", - "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", - "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]", - "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", - "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", - "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]", - "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]", - "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", - "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]", - "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]", - "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]", - "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]", - "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]", - "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", - "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]", - "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]", - "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]", - "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]", - "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]", - "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]", - "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]", - "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]", - "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]", - "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]", - "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]", - "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]", - "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]", - "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]", - "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]", - "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]", - "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]", - "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]", - "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]", - "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]", - "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]", - "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]", - "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]", - "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]", - "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]", - "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]", - "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]", - "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]", - "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]", - "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]", - "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]", - "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]", - "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]", - "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]", - "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]", - "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]", - "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]", - "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]", - "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]", - "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]", - "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]", - "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]", - "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]", - "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]", - "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", - "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", - "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", - "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]", - "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]", - "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]", - "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]", - "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]", - "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]", - "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]", - "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]", - "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]", - "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", - "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]", - "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]", - "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]", - "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]", - "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]", - "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]", - "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]", - "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]", - "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]", - "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]", - "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]", - "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]", - "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]", - "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]", - "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]", - "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]", - "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]", - "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]", - "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]", - "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]", - "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]", - "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]", - "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]", - "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]", - "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]", - "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]", - "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]", - "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]", - "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]", - "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]", - "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]", - "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]", - "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]", - "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]", - "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]", - "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]", - "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]", - "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]", - "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]", - "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]", - "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]", - "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]", - "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]", - "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]", - "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]", - "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]", - "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]", - "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]", - "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]", - "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]", - "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]", - "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]", - "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]", - "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]", - "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]", - "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]", - "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]", - "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]", - "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]" + "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]", + "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]", + "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]", + "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]", + "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]", + "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]", + "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]", + "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]", + "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]", + "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]", + "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]", + "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]", + "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]", + "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]", + "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]", + "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]", + "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]", + "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]", + "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]", + "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]", + "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]", + "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]", + "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]", + "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]", + "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]", + "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]", + "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]", + "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]", + "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]", + "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]", + "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]", + "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]", + "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]", + "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]", + "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]", + "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]", + "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]", + "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]", + "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]", + "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]", + "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]", + "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]", + "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]", + "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]", + "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]", + "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]", + "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]", + "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]", + "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]", + "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]", + "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]", + "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]", + "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]", + "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]", + "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]", + "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]", + "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]", + "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]", + "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]", + "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]", + "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]", + "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]", + "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]", + "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]", + "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]", + "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]", + "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]", + "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]", + "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]", + "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]", + "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]", + "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]", + "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]", + "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]", + "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]", + "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]", + "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]", + "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]", + "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]", + "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]", + "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]", + "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]", + "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]", + "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]", + "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]", + "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]", + "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]", + "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]", + "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]", + "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]", + "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]", + "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]", + "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]", + "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]", + "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]", + "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]", + "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]", + "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]", + "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]", + "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]", + "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]", + "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]", + "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]", + "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]", + "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]", + "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]", + "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]", + "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]", + "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]", + "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]", + "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]", + "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]", + "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]", + "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]", + "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]", + "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]", + "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]", + "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]", + "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]", + "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]", + "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]", + "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]", + "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]", + "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]", + "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]", + "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]", + "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]", + "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]", + "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]", + "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]", + "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]", + "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]", + "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]", + "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]", + "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]", + "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]", + "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]", + "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]", + "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]", + "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]", + "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]", + "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]", + "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]", + "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]", + "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]", + "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]" } } }, diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 4061d5ed863..7b74c2290c3 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,6 +11,7 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfEvents, ArrayOfHomeAppliances, + ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, ArrayOfStatus, @@ -199,13 +200,13 @@ def _get_set_program_side_effect( return set_program_side_effect -def _get_set_key_value_side_effect( - event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str +def _get_set_setting_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], ): - """Set program options side effect.""" + """Set settings side effect.""" - async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None: - event_key = EventKey(kwargs[parameter_key]) + async def set_settings_side_effect(ha_id: str, *_, **kwargs) -> None: + event_key = EventKey(kwargs["setting_key"]) await event_queue.put( [ EventMessage( @@ -227,7 +228,48 @@ def _get_set_key_value_side_effect( ] ) - return set_key_value_side_effect + return set_settings_side_effect + + +def _get_set_program_options_side_effect( + event_queue: asyncio.Queue[list[EventMessage]], +): + """Set programs side effect.""" + + async def set_program_options_side_effect(ha_id: str, *_, **kwargs) -> None: + await event_queue.put( + [ + EventMessage( + ha_id, + EventType.NOTIFY, + ArrayOfEvents( + [ + Event( + key=EventKey(option.key), + raw_key=option.key.value, + timestamp=0, + level="", + handling="", + value=option.value, + ) + for option in ( + cast(ArrayOfOptions, kwargs["array_of_options"]).options + if "array_of_options" in kwargs + else [ + Option( + kwargs["option_key"], + kwargs["value"], + unit=kwargs["unit"], + ) + ] + ) + ] + ), + ), + ] + ) + + return set_program_options_side_effect async def _get_all_programs_side_effect(ha_id: str) -> ArrayOfPrograms: @@ -319,13 +361,19 @@ def mock_client(request: pytest.FixtureRequest) -> MagicMock: ), ) mock.set_active_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_active_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_selected_program_option = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "option_key"), + side_effect=_get_set_program_options_side_effect(event_queue), + ) + mock.set_selected_program_options = AsyncMock( + side_effect=_get_set_program_options_side_effect(event_queue), ) mock.set_setting = AsyncMock( - side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"), + side_effect=_get_set_setting_side_effect(event_queue), ) mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect) mock.get_setting = AsyncMock(side_effect=_get_setting_side_effect) @@ -363,7 +411,9 @@ def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock: mock.stop_program = AsyncMock(side_effect=exception) mock.set_selected_program = AsyncMock(side_effect=exception) mock.set_active_program_option = AsyncMock(side_effect=exception) + mock.set_active_program_options = AsyncMock(side_effect=exception) mock.set_selected_program_option = AsyncMock(side_effect=exception) + mock.set_selected_program_options = AsyncMock(side_effect=exception) mock.set_setting = AsyncMock(side_effect=exception) mock.get_settings = AsyncMock(side_effect=exception) mock.get_setting = AsyncMock(side_effect=exception) diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr new file mode 100644 index 00000000000..581eca66cb8 --- /dev/null +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_set_program_and_options[service_call0-set_selected_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 1800, + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call1-start_program] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal', + }), + ]), + 'program_key': , + }), + ) +# --- +# name: test_set_program_and_options[service_call2-set_active_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 60, + }), + ]), + }), + }), + ) +# --- +# name: test_set_program_and_options[service_call3-set_selected_program_options] + _Call( + tuple( + 'SIEMENS-HCS03WCH1-7BC6383CF794', + ), + dict({ + 'array_of_options': dict({ + 'options': list([ + dict({ + 'display_value': None, + 'key': , + 'name': None, + 'unit': None, + 'value': 35, + }), + ]), + }), + }), + ) +# --- diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 009c40b662d..e7380d0e255 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -1,6 +1,7 @@ """Test the integration init functionality.""" from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ from aiohomeconnect.model.error import HomeConnectError, UnauthorizedError import pytest import requests_mock import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.home_connect.const import DOMAIN @@ -22,6 +24,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +import homeassistant.helpers.issue_registry as ir from script.hassfest.translations import RE_TRANSLATION_KEY from .conftest import ( @@ -34,8 +37,9 @@ from .conftest import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator -SERVICE_KV_CALL_PARAMS = [ +DEPRECATED_SERVICE_KV_CALL_PARAMS = [ { "domain": DOMAIN, "service": "set_option_active", @@ -57,6 +61,10 @@ SERVICE_KV_CALL_PARAMS = [ }, "blocking": True, }, +] + +SERVICE_KV_CALL_PARAMS = [ + *DEPRECATED_SERVICE_KV_CALL_PARAMS, { "domain": DOMAIN, "service": "change_setting", @@ -125,6 +133,62 @@ SERVICE_APPLIANCE_METHOD_MAPPING = { "start_program": "start_program", } +SERVICE_VALIDATION_ERROR_MAPPING = { + "set_option_active": r"Error.*setting.*options.*active.*program.*", + "set_option_selected": r"Error.*setting.*options.*selected.*program.*", + "change_setting": r"Error.*assigning.*value.*setting.*", + "pause_program": r"Error.*executing.*command.*", + "resume_program": r"Error.*executing.*command.*", + "select_program": r"Error.*selecting.*program.*", + "start_program": r"Error.*starting.*program.*", +} + + +SERVICES_SET_PROGRAM_AND_OPTIONS = [ + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "program": "dishcare_dishwasher_program_eco_50", + "b_s_h_common_option_start_in_relative": "00:30:00", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "program": "consumer_products_coffee_maker_program_beverage_coffee", + "consumer_products_coffee_maker_option_bean_amount": "consumer_products_coffee_maker_enum_type_bean_amount_normal", + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "active_program", + "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + }, + "blocking": True, + }, + { + "domain": DOMAIN, + "service": "set_program_and_options", + "service_data": { + "device_id": "DEVICE_ID", + "affects_to": "selected_program", + "consumer_products_coffee_maker_option_fill_quantity": 35, + }, + "blocking": True, + }, +] + async def test_entry_setup( hass: HomeAssistant, @@ -244,7 +308,7 @@ async def test_client_error( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services( +async def test_key_value_services( service_call: dict[str, Any], hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -273,11 +337,188 @@ async def test_services( ) +@pytest.mark.parametrize( + "service_call", + DEPRECATED_SERVICE_KV_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_programs_and_options_actions_deprecation( + service_call: dict[str, Any], + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test deprecated service keys.""" + issue_id = "deprecated_set_program_and_option_actions" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue + + _client = await hass_client() + resp = await _client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + assert resp.status == HTTPStatus.OK + flow_id = (await resp.json())["flow_id"] + resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + assert issue_registry.async_get_issue(DOMAIN, issue_id) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + ("service_call", "called_method"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + "set_selected_program", + "start_program", + "set_active_program_options", + "set_selected_program_options", + ], + strict=True, + ), +) +async def test_set_program_and_options( + service_call: dict[str, Any], + called_method: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, + snapshot: SnapshotAssertion, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + await hass.services.async_call(**service_call) + await hass.async_block_till_done() + method_mock: MagicMock = getattr(client, called_method) + assert method_mock.call_count == 1 + assert method_mock.call_args == snapshot + + +@pytest.mark.parametrize( + ("service_call", "error_regex"), + zip( + SERVICES_SET_PROGRAM_AND_OPTIONS, + [ + r"Error.*selecting.*program.*", + r"Error.*starting.*program.*", + r"Error.*setting.*options.*active.*program.*", + r"Error.*setting.*options.*selected.*program.*", + ], + strict=True, + ), +) +async def test_set_program_and_options_exceptions( + service_call: dict[str, Any], + error_regex: str, + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, +) -> None: + """Test recognized options.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + with pytest.raises(HomeAssistantError, match=error_regex): + await hass.services.async_call(**service_call) + + +async def test_required_program_or_at_least_an_option( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + "Test that the set_program_and_options does raise an exception if no program nor options are set." + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + with pytest.raises( + ServiceValidationError, + ): + await hass.services.async_call( + DOMAIN, + "set_program_and_options", + { + "device_id": device_entry.id, + "affects_to": "selected_program", + }, + True, + ) + + @pytest.mark.parametrize( "service_call", SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, ) -async def test_services_exception( +async def test_services_exception_device_id( service_call: dict[str, Any], hass: HomeAssistant, config_entry: MockConfigEntry, @@ -348,6 +589,40 @@ async def test_services_appliance_not_found( await hass.services.async_call(**service_call) +@pytest.mark.parametrize( + "service_call", + SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS, +) +async def test_services_exception( + service_call: dict[str, Any], + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client_with_exception: MagicMock, + appliance_ha_id: str, + device_registry: dr.DeviceRegistry, +) -> None: + """Raise a ValueError when device id does not match.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client_with_exception) + assert config_entry.state == ConfigEntryState.LOADED + + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, appliance_ha_id)}, + ) + + service_call["service_data"]["device_id"] = device_entry.id + + service_name = service_call["service"] + with pytest.raises( + HomeAssistantError, + match=SERVICE_VALIDATION_ERROR_MAPPING[service_name], + ): + await hass.services.async_call(**service_call) + + async def test_entity_migration( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From c090fbfbadff4e01411722320113c6ddb1ba0c1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 14 Feb 2025 20:21:30 +0100 Subject: [PATCH 42/66] Add binary sensor platform to LetPot integration (#138554) --- homeassistant/components/letpot/__init__.py | 7 +- .../components/letpot/binary_sensor.py | 122 +++++++ homeassistant/components/letpot/icons.json | 20 ++ .../components/letpot/quality_scale.yaml | 2 +- homeassistant/components/letpot/strings.json | 17 + tests/components/letpot/__init__.py | 18 +- tests/components/letpot/conftest.py | 63 +++- .../letpot/snapshots/test_binary_sensor.ambr | 337 ++++++++++++++++++ tests/components/letpot/test_binary_sensor.py | 32 ++ 9 files changed, 596 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/letpot/binary_sensor.py create mode 100644 tests/components/letpot/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/letpot/test_binary_sensor.py diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index dc322d5641b..50c73f949a3 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -22,7 +22,12 @@ from .const import ( ) from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH, Platform.TIME] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool: diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py new file mode 100644 index 00000000000..bfc7a5ab4a7 --- /dev/null +++ b/homeassistant/components/letpot/binary_sensor.py @@ -0,0 +1,122 @@ +"""Support for LetPot binary sensor entities.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from letpot.models import DeviceFeature, LetPotDeviceStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator +from .entity import LetPotEntity, LetPotEntityDescription + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LetPotBinarySensorEntityDescription( + LetPotEntityDescription, BinarySensorEntityDescription +): + """Describes a LetPot binary sensor entity.""" + + is_on_fn: Callable[[LetPotDeviceStatus], bool] + + +BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( + LetPotBinarySensorEntityDescription( + key="low_nutrients", + translation_key="low_nutrients", + is_on_fn=lambda status: bool(status.errors.low_nutrients), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.low_nutrients is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="low_water", + translation_key="low_water", + is_on_fn=lambda status: bool(status.errors.low_water), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None, + ), + LetPotBinarySensorEntityDescription( + key="pump", + translation_key="pump", + is_on_fn=lambda status: status.pump_status == 1, + device_class=BinarySensorDeviceClass.RUNNING, + supported_fn=( + lambda coordinator: DeviceFeature.PUMP_STATUS + in coordinator.device_client.device_features + ), + ), + LetPotBinarySensorEntityDescription( + key="pump_error", + translation_key="pump_error", + is_on_fn=lambda status: bool(status.errors.pump_malfunction), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.pump_malfunction is not None + ), + ), + LetPotBinarySensorEntityDescription( + key="refill_error", + translation_key="refill_error", + is_on_fn=lambda status: bool(status.errors.refill_error), + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.PROBLEM, + supported_fn=( + lambda coordinator: coordinator.data.errors.refill_error is not None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LetPotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LetPot binary sensor entities based on a config entry and device status/features.""" + coordinators = entry.runtime_data + async_add_entities( + LetPotBinarySensorEntity(coordinator, description) + for description in BINARY_SENSORS + for coordinator in coordinators + if description.supported_fn(coordinator) + ) + + +class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity): + """Defines a LetPot binary sensor entity.""" + + entity_description: LetPotBinarySensorEntityDescription + + def __init__( + self, + coordinator: LetPotDeviceCoordinator, + description: LetPotBinarySensorEntityDescription, + ) -> None: + """Initialize LetPot binary sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}" + + @property + def is_on(self) -> bool: + """Return if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json index 60cba78fa1c..43541b57150 100644 --- a/homeassistant/components/letpot/icons.json +++ b/homeassistant/components/letpot/icons.json @@ -1,5 +1,25 @@ { "entity": { + "binary_sensor": { + "low_nutrients": { + "default": "mdi:beaker-alert", + "state": { + "off": "mdi:beaker" + } + }, + "low_water": { + "default": "mdi:water-percent-alert", + "state": { + "off": "mdi:water-percent" + } + }, + "pump": { + "default": "mdi:pump", + "state": { + "off": "mdi:pump-off" + } + } + }, "sensor": { "water_level": { "default": "mdi:water-percent" diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 0fdaca18717..9804a5ec3a4 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -61,7 +61,7 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: done diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json index 0cb79ce711c..cdc5a36a15f 100644 --- a/homeassistant/components/letpot/strings.json +++ b/homeassistant/components/letpot/strings.json @@ -32,6 +32,23 @@ } }, "entity": { + "binary_sensor": { + "low_nutrients": { + "name": "Low nutrients" + }, + "low_water": { + "name": "Low water" + }, + "pump": { + "name": "Pump" + }, + "pump_error": { + "name": "Pump error" + }, + "refill_error": { + "name": "Refill error" + } + }, "sensor": { "water_level": { "name": "Water level" diff --git a/tests/components/letpot/__init__.py b/tests/components/letpot/__init__.py index d4570ce44be..6e73bb430cf 100644 --- a/tests/components/letpot/__init__.py +++ b/tests/components/letpot/__init__.py @@ -30,7 +30,7 @@ AUTHENTICATION = AuthenticationInfo( email="email@example.com", ) -STATUS = LetPotDeviceStatus( +MAX_STATUS = LetPotDeviceStatus( errors=LetPotDeviceErrors(low_water=True, low_nutrients=False, refill_error=False), light_brightness=500, light_mode=1, @@ -49,3 +49,19 @@ STATUS = LetPotDeviceStatus( water_mode=1, water_level=100, ) + +SE_STATUS = LetPotDeviceStatus( + errors=LetPotDeviceErrors(low_water=True, pump_malfunction=True), + light_brightness=500, + light_mode=1, + light_schedule_end=datetime.time(18, 0), + light_schedule_start=datetime.time(8, 0), + online=True, + plant_days=1, + pump_mode=1, + pump_nutrient=None, + pump_status=0, + raw=[], # Not used by integration, and it requires a real device to get + system_on=True, + system_sound=False, +) diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 454d4e235db..25974b2d78a 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice +from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus import pytest from homeassistant.components.letpot.const import ( @@ -15,11 +15,42 @@ from homeassistant.components.letpot.const import ( ) from homeassistant.const import CONF_ACCESS_TOKEN, CONF_EMAIL -from . import AUTHENTICATION, STATUS +from . import AUTHENTICATION, MAX_STATUS, SE_STATUS from tests.common import MockConfigEntry +@pytest.fixture +def device_type() -> str: + """Return the device type to use for mock data.""" + return "LPH63" + + +def _mock_device_features(device_type: str) -> DeviceFeature: + """Return mock device feature support for the given type.""" + if device_type == "LPH31": + return DeviceFeature.LIGHT_BRIGHTNESS_LOW_HIGH | DeviceFeature.PUMP_STATUS + if device_type == "LPH63": + return ( + DeviceFeature.LIGHT_BRIGHTNESS_LEVELS + | DeviceFeature.NUTRIENT_BUTTON + | DeviceFeature.PUMP_AUTO + | DeviceFeature.PUMP_STATUS + | DeviceFeature.TEMPERATURE + | DeviceFeature.WATER_LEVEL + ) + raise ValueError(f"No mock data for device type {device_type}") + + +def _mock_device_status(device_type: str) -> LetPotDeviceStatus: + """Return mock device status for the given type.""" + if device_type == "LPH31": + return SE_STATUS + if device_type == "LPH63": + return MAX_STATUS + raise ValueError(f"No mock data for device type {device_type}") + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -30,7 +61,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_client() -> Generator[AsyncMock]: +def mock_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotClient.""" with ( patch( @@ -47,9 +78,9 @@ def mock_client() -> Generator[AsyncMock]: client.refresh_token.return_value = AUTHENTICATION client.get_devices.return_value = [ LetPotDevice( - serial_number="LPH63ABCD", + serial_number=f"{device_type}ABCD", name="Garden", - device_type="LPH63", + device_type=device_type, is_online=True, is_remote=False, ) @@ -58,23 +89,17 @@ def mock_client() -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client() -> Generator[AsyncMock]: +def mock_device_client(device_type: str) -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( "homeassistant.components.letpot.coordinator.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = ( - DeviceFeature.LIGHT_BRIGHTNESS_LEVELS - | DeviceFeature.NUTRIENT_BUTTON - | DeviceFeature.PUMP_AUTO - | DeviceFeature.PUMP_STATUS - | DeviceFeature.TEMPERATURE - | DeviceFeature.WATER_LEVEL - ) - device_client.device_model_code = "LPH63" - device_client.device_model_name = "LetPot Max" + device_client.device_features = _mock_device_features(device_type) + device_client.device_model_code = device_type + device_client.device_model_name = f"LetPot {device_type}" + device_status = _mock_device_status(device_type) subscribe_callbacks: list[Callable] = [] @@ -84,11 +109,11 @@ def mock_device_client() -> Generator[AsyncMock]: def status_side_effect() -> None: # Deliver a status update to any subscribers, like the real client for callback in subscribe_callbacks: - callback(STATUS) + callback(device_status) device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = STATUS - device_client.last_status.return_value = STATUS + device_client.get_current_status.return_value = device_status + device_client.last_status.return_value = device_status device_client.request_status_update.side_effect = status_side_effect device_client.subscribe.side_effect = subscribe_side_effect diff --git a/tests/components/letpot/snapshots/test_binary_sensor.ambr b/tests/components/letpot/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..121cf4e3f82 --- /dev/null +++ b/tests/components/letpot/snapshots/test_binary_sensor.ambr @@ -0,0 +1,337 @@ +# serializer version: 1 +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH31ABCD_pump_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH31][binary_sensor.garden_pump_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Pump error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low nutrients', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_nutrients', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_nutrients', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_nutrients-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low nutrients', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_nutrients', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_low_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Low water', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'low_water', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_low_water', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_low_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Low water', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_low_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.garden_pump', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Pump', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'pump', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_pump', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_pump-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'Garden Pump', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_pump', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Refill error', + 'platform': 'letpot', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refill_error', + 'unique_id': 'a1b2c3d4e5f6a1b2c3d4e5f6_LPH63ABCD_refill_error', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[LPH63][binary_sensor.garden_refill_error-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Garden Refill error', + }), + 'context': , + 'entity_id': 'binary_sensor.garden_refill_error', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/letpot/test_binary_sensor.py b/tests/components/letpot/test_binary_sensor.py new file mode 100644 index 00000000000..03ce1bee1a5 --- /dev/null +++ b/tests/components/letpot/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Test binary sensor entities for the LetPot integration.""" + +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize("device_type", ["LPH63", "LPH31"]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_client: MagicMock, + mock_device_client: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + device_type: str, +) -> None: + """Test binary sensor entities.""" + with patch("homeassistant.components.letpot.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 58797a14e7c54be566eb869dd59cc5410923155c Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:48:19 +0100 Subject: [PATCH 43/66] Add 6 new sensors to qBittorrent integration (#138446) Co-authored-by: Josef Zweck --- .../components/qbittorrent/sensor.py | 108 +++++++++++++++++- .../components/qbittorrent/strings.json | 23 ++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 9f4610cff64..23ec485fcd4 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) SENSOR_TYPE_CURRENT_STATUS = "current_status" +SENSOR_TYPE_CONNECTION_STATUS = "connection_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" +SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit" +SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit" +SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download" +SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload" +SENSOR_TYPE_GLOBAL_RATIO = "global_ratio" SENSOR_TYPE_ALL_TORRENTS = "all_torrents" SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents" SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents" @@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str: return STATE_IDLE -def get_dl(coordinator: QBittorrentDataCoordinator) -> int: +def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str: + """Get current download/upload state.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(str, server_state.get("connection_status")) + + +def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current download speed.""" server_state = cast(Mapping, coordinator.data.get("server_state")) return cast(int, server_state.get("dl_info_speed")) -def get_up(coordinator: QBittorrentDataCoordinator) -> int: +def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int: """Get current upload speed.""" server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) return cast(int, server_state.get("up_info_speed")) +def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("dl_rate_limit")) + + +def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int: + """Get current upload speed.""" + server_state = cast(Mapping[str, Any], coordinator.data.get("server_state")) + return cast(int, server_state.get("up_rate_limit")) + + +def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_dl")) + + +def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(int, server_state.get("alltime_ul")) + + +def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float: + """Get current download speed.""" + server_state = cast(Mapping, coordinator.data.get("server_state")) + return cast(float, server_state.get("global_ratio")) + + @dataclass(frozen=True, kw_only=True) class QBittorrentSensorEntityDescription(SensorEntityDescription): """Entity description class for qBittorent sensors.""" @@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING], value_fn=get_state, ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_CONNECTION_STATUS, + translation_key="connection_status", + device_class=SensorDeviceClass.ENUM, + options=["connected", "firewalled", "disconnected"], + value_fn=get_connection_status, + ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, translation_key="download_speed", @@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_dl, + value_fn=get_download_speed, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, @@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, suggested_display_precision=2, suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, - value_fn=get_up, + value_fn=get_upload_speed, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT, + translation_key="download_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_download_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT, + translation_key="upload_speed_limit", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + value_fn=get_upload_speed_limit, + entity_registry_enabled_default=False, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_DOWNLOAD, + translation_key="alltime_download", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_display_precision=2, + suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES, + value_fn=get_alltime_download, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_ALLTIME_UPLOAD, + translation_key="alltime_upload", + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement="B", + suggested_display_precision=2, + suggested_unit_of_measurement="TiB", + value_fn=get_alltime_upload, + ), + QBittorrentSensorEntityDescription( + key=SENSOR_TYPE_GLOBAL_RATIO, + translation_key="global_ratio", + state_class=SensorStateClass.MEASUREMENT, + value_fn=get_global_ratio, + entity_registry_enabled_default=False, ), QBittorrentSensorEntityDescription( key=SENSOR_TYPE_ALL_TORRENTS, diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json index 9c9ee371737..83d93766ee4 100644 --- a/homeassistant/components/qbittorrent/strings.json +++ b/homeassistant/components/qbittorrent/strings.json @@ -26,6 +26,21 @@ "upload_speed": { "name": "Upload speed" }, + "download_speed_limit": { + "name": "Download speed limit" + }, + "upload_speed_limit": { + "name": "Upload speed limit" + }, + "alltime_download": { + "name": "Alltime download" + }, + "alltime_upload": { + "name": "Alltime upload" + }, + "global_ratio": { + "name": "Global ratio" + }, "current_status": { "name": "Status", "state": { @@ -35,6 +50,14 @@ "downloading": "Downloading" } }, + "connection_status": { + "name": "Connection status", + "state": { + "connected": "Conencted", + "firewalled": "Firewalled", + "disconnected": "Disconnected" + } + }, "active_torrents": { "name": "Active torrents", "unit_of_measurement": "torrents" From b916fbe1fc50b375233677079da97a573aebadf1 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 14 Feb 2025 12:50:51 -0700 Subject: [PATCH 44/66] Add time entity to balboa (#138248) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 8 + homeassistant/components/balboa/time.py | 56 ++++++ tests/components/balboa/conftest.py | 5 + .../balboa/snapshots/test_time.ambr | 189 ++++++++++++++++++ tests/components/balboa/test_time.py | 72 +++++++ 6 files changed, 331 insertions(+) create mode 100644 homeassistant/components/balboa/time.py create mode 100644 tests/components/balboa/snapshots/test_time.ambr create mode 100644 tests/components/balboa/test_time.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index c982d59d513..78bf6f7cda7 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index c00567a6052..0262f26f4bd 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -78,6 +78,14 @@ "high": "High" } } + }, + "time": { + "filter_cycle_start": { + "name": "Filter cycle {index} start" + }, + "filter_cycle_end": { + "name": "Filter cycle {index} end" + } } } } diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py new file mode 100644 index 00000000000..83467de8777 --- /dev/null +++ b/homeassistant/components/balboa/time.py @@ -0,0 +1,56 @@ +"""Support for Balboa times.""" + +from __future__ import annotations + +from datetime import time +import itertools +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.time import TimeEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + +FILTER_CYCLE = "filter_cycle_" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's times.""" + spa = entry.runtime_data + async_add_entities( + BalboaTimeEntity(spa, index, period) + for index, period in itertools.product((1, 2), ("start", "end")) + ) + + +class BalboaTimeEntity(BalboaEntity, TimeEntity): + """Representation of a Balboa time entity.""" + + entity_category = EntityCategory.CONFIG + + def __init__(self, spa: SpaClient, index: int, period: str) -> None: + """Initialize a Balboa time entity.""" + super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}") + self.index = index + self.period = period + self._attr_translation_key = f"{FILTER_CYCLE}{period}" + self._attr_translation_placeholders = {"index": str(index)} + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}") + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + args: dict[str, Any] = {self.period: value} + await self._client.configure_filter_cycle(self.index, **args) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 0bb8b2cd468..3a3561f15cf 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Generator +from datetime import time from unittest.mock import AsyncMock, MagicMock, patch from pybalboa.enums import HeatMode, LowHighRange @@ -48,7 +49,11 @@ def client_fixture() -> Generator[MagicMock]: client.blowers = [] client.circulation_pump.state = 0 client.filter_cycle_1_running = False + client.filter_cycle_1_start = time(8, 0) + client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_start = time(19, 0) + client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 client.temperature = 10 client.temperature_minimum = 10 diff --git a/tests/components/balboa/snapshots/test_time.ambr b/tests/components/balboa/snapshots/test_time.ambr new file mode 100644 index 00000000000..6b27717e2d3 --- /dev/null +++ b/tests/components/balboa/snapshots/test_time.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_times[time.fakespa_filter_cycle_1_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_1_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '09:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 1 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_1_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_1_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 1 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_1_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '08:00:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 end', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_end', + 'unique_id': 'FakeSpa-filter_cycle_2_end-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_end-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 end', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_end', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21:30:00', + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'time', + 'entity_category': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 start', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_start', + 'unique_id': 'FakeSpa-filter_cycle_2_start-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_times[time.fakespa_filter_cycle_2_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 start', + }), + 'context': , + 'entity_id': 'time.fakespa_filter_cycle_2_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19:00:00', + }) +# --- diff --git a/tests/components/balboa/test_time.py b/tests/components/balboa/test_time.py new file mode 100644 index 00000000000..21778d08e2d --- /dev/null +++ b/tests/components/balboa/test_time.py @@ -0,0 +1,72 @@ +"""Tests of the times of the balboa integration.""" + +from __future__ import annotations + +from datetime import time +from unittest.mock import MagicMock, patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.time import ( + ATTR_TIME, + DOMAIN as TIME_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform + +ENTITY_TIME = "time.fakespa_" + + +async def test_times( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa times.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.TIME]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("filter_cycle", "period", "value"), + [ + (1, "start", "08:00:00"), + (1, "end", "09:00:00"), + (2, "start", "19:00:00"), + (2, "end", "21:30:00"), + ], +) +async def test_time( + hass: HomeAssistant, client: MagicMock, filter_cycle: int, period: str, value: str +) -> None: + """Test spa filter cycle time.""" + await init_integration(hass) + + time_entity = f"{ENTITY_TIME}filter_cycle_{filter_cycle}_{period}" + + # check the expected state of the time entity + state = hass.states.get(time_entity) + assert state.state == value + + new_time = time(hour=7, minute=0) + + await hass.services.async_call( + TIME_DOMAIN, + SERVICE_SET_VALUE, + service_data={ATTR_TIME: new_time}, + blocking=True, + target={ATTR_ENTITY_ID: time_entity}, + ) + + # check we made a call with the right parameters + client.configure_filter_cycle.assert_called_with(filter_cycle, **{period: new_time}) From 28dd44504e614eccf9bb2bf7d84ba5060910c426 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 14:42:36 -0600 Subject: [PATCH 45/66] Bump aioesphomeapi to 29.0.2 (#138549) changelog: https://github.com/esphome/aioesphomeapi/compare/v29.0.0...v29.0.2 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 185f9ea5cf0..8f9f06e6967 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "mqtt": ["esphome/discover/#"], "requirements": [ - "aioesphomeapi==29.0.0", + "aioesphomeapi==29.0.2", "esphome-dashboard-api==1.2.3", "bleak-esphome==2.7.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 43f850d14ce..447166213c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2877dfacfe..daf8ff556c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==29.0.0 +aioesphomeapi==29.0.2 # homeassistant.components.flo aioflo==2021.11.0 From e16343ed727ac1e105105bae4445ac8178daa5c2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 15:41:45 -0600 Subject: [PATCH 46/66] Prevent voice wizard from crashing for wyoming/voip (#138547) * Prevent voice wizard from crashing for wyoming/voip * Use stub configuration in websocket API --- .../assist_satellite/websocket_api.py | 12 ++++++- .../assist_satellite/test_websocket_api.py | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 6cd7af2bbdb..4fc1708b866 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -19,6 +19,7 @@ from .const import ( DOMAIN, AssistSatelliteEntityFeature, ) +from .entity import AssistSatelliteConfiguration CONNECTION_TEST_TIMEOUT = 30 @@ -91,7 +92,16 @@ def websocket_get_configuration( ) return - config_dict = asdict(satellite.async_get_configuration()) + try: + config_dict = asdict(satellite.async_get_configuration()) + except NotImplementedError: + # Stub configuration + config_dict = asdict( + AssistSatelliteConfiguration( + available_wake_words=[], active_wake_words=[], max_active_wake_words=1 + ) + ) + config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id diff --git a/tests/components/assist_satellite/test_websocket_api.py b/tests/components/assist_satellite/test_websocket_api.py index 257961a5b32..f0a8f02fc50 100644 --- a/tests/components/assist_satellite/test_websocket_api.py +++ b/tests/components/assist_satellite/test_websocket_api.py @@ -313,6 +313,37 @@ async def test_get_configuration( } +async def test_get_configuration_not_implemented( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test getting stub satellite configuration when the entity doesn't implement the method.""" + ws_client = await hass_ws_client(hass) + + with patch.object( + entity, "async_get_configuration", side_effect=NotImplementedError() + ): + await ws_client.send_json_auto_id( + { + "type": "assist_satellite/get_configuration", + "entity_id": ENTITY_ID, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + + # Stub configuration + assert msg["result"] == { + "active_wake_words": [], + "available_wake_words": [], + "max_active_wake_words": 1, + "pipeline_entity_id": None, + "vad_entity_id": None, + } + + async def test_set_wake_words( hass: HomeAssistant, init_components: ConfigEntry, From 4a4c2ff55211498f2344860d6e88472c391b5acd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 14 Feb 2025 16:17:35 -0800 Subject: [PATCH 47/66] Bump zeroconf to 0.144.3 (#138553) --- homeassistant/components/zeroconf/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/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index ddc74fba8bf..7a17c0dc5c3 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.144.1"] + "requirements": ["zeroconf==0.144.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b7592bf0f05..7aa76de2620 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -73,7 +73,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 yarl==1.18.3 -zeroconf==0.144.1 +zeroconf==0.144.3 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/pyproject.toml b/pyproject.toml index 553ced3da43..66b25b75f92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ dependencies = [ "voluptuous-openapi==0.0.6", "yarl==1.18.3", "webrtc-models==0.3.0", - "zeroconf==0.144.1" + "zeroconf==0.144.3" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 2b7290fa042..2cbd3780eae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ voluptuous-serialize==2.6.0 voluptuous-openapi==0.0.6 yarl==1.18.3 webrtc-models==0.3.0 -zeroconf==0.144.1 +zeroconf==0.144.3 diff --git a/requirements_all.txt b/requirements_all.txt index 447166213c3..ccc401d4f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3131,7 +3131,7 @@ zamg==0.3.6 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf8ff556c8..4831ca47990 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2520,7 +2520,7 @@ yt-dlp[default]==2025.01.26 zamg==0.3.6 # homeassistant.components.zeroconf -zeroconf==0.144.1 +zeroconf==0.144.3 # homeassistant.components.zeversolar zeversolar==0.3.2 From 30a6a6ad4bf9ff4ea7780e33aeff2cd23c31889b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 14 Feb 2025 19:51:53 -0600 Subject: [PATCH 48/66] Use language util to match intent language (#138560) --- .../components/conversation/default_agent.py | 15 +++----- .../conversation/test_default_agent.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index 23c201d7579..e8bd38f5adf 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -53,6 +53,7 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_added_domain +from homeassistant.util import language as language_util from homeassistant.util.json import JsonObjectType, json_loads_object from .chat_log import AssistantContent, async_get_chat_log @@ -914,26 +915,20 @@ class DefaultAgent(ConversationEntity): def _load_intents(self, language: str) -> LanguageIntents | None: """Load all intents for language (run inside executor).""" intents_dict: dict[str, Any] = {} - language_variant: str | None = None supported_langs = set(get_languages()) # Choose a language variant upfront and commit to it for custom # sentences, etc. - all_language_variants = {lang.lower(): lang for lang in supported_langs} + lang_matches = language_util.matches(language, supported_langs) - # en-US, en_US, en, ... - for maybe_variant in _get_language_variations(language): - matching_variant = all_language_variants.get(maybe_variant.lower()) - if matching_variant: - language_variant = matching_variant - break - - if not language_variant: + if not lang_matches: _LOGGER.warning( "Unable to find supported language variant for %s", language ) return None + language_variant = lang_matches[0] + # Load intents for this language variant lang_variant_intents = get_intents(language_variant, json_load=json_load) diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 54aa30b3fcf..d9f9917b9e0 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -3178,3 +3178,39 @@ async def test_state_names_are_not_translated( mock_async_render.call_args.args[0]["state"].state == weather.ATTR_CONDITION_PARTLYCLOUDY ) + + +async def test_language_with_alternative_code( + hass: HomeAssistant, init_components +) -> None: + """Test different codes for the same language.""" + entity_ids: dict[str, str] = {} + for i, (lang_code, sentence, name) in enumerate( + ( + ("no", "slå på lampen", "lampen"), # nb + ("no-NO", "slå på lampen", "lampen"), # nb + ("iw", "הדליקי את המנורה", "מנורה"), # he + ) + ): + if not (entity_id := entity_ids.get(name)): + # Reuse entity id for the same name + entity_id = f"light.test{i}" + entity_ids[name] = entity_id + + hass.states.async_set(entity_id, "off", attributes={ATTR_FRIENDLY_NAME: name}) + calls = async_mock_service(hass, LIGHT_DOMAIN, "turn_on") + await hass.services.async_call( + "conversation", + "process", + { + conversation.ATTR_TEXT: sentence, + conversation.ATTR_LANGUAGE: lang_code, + }, + ) + await hass.async_block_till_done() + + assert len(calls) == 1, f"Failed for {lang_code}, {sentence}" + call = calls[0] + assert call.domain == LIGHT_DOMAIN + assert call.service == "turn_on" + assert call.data == {"entity_id": [entity_id]} From 7a23348b1da8b7f4ba025a61f81e26d88f2c5749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 11:29:40 +0100 Subject: [PATCH 49/66] Fix and improve Home Connect strings (#138583) * Fix `hot_water_temperature` strings for tea options * Improve `deprecated_program_switch` issue description Co-authored-by: Norbert Rittel * Improve option descriptions strings Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --------- Co-authored-by: Norbert Rittel Co-authored-by: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> --- .../components/home_connect/strings.json | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 38fdd6f6ec3..8bee37796ad 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -107,7 +107,7 @@ }, "deprecated_program_switch": { "title": "Deprecated program switch detected in some automations or scripts", - "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue." + "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue." }, "deprecated_set_program_and_option_actions": { "title": "The executed action is deprecated", @@ -346,9 +346,9 @@ }, "hot_water_temperature": { "options": { - "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": ".WhiteTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": ".GreenTea", - "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": ".BlackTea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "Green tea", + "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "Black tea", "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC", "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC", @@ -509,7 +509,7 @@ }, "consumer_products_cleaning_robot_option_reference_map_id": { "name": "Reference map ID", - "description": "Defines the used reference map." + "description": "Defines which reference map is to be used." }, "consumer_products_cleaning_robot_option_cleaning_mode": { "name": "Cleaning mode", @@ -517,15 +517,15 @@ }, "consumer_products_coffee_maker_option_bean_amount": { "name": "Bean amount", - "description": "Describes the bean amount of a coffee machine program." + "description": "Describes the amount of coffee beans used in a coffee machine program." }, "consumer_products_coffee_maker_option_fill_quantity": { "name": "Fill quantity", - "description": "Describes the fill quantity (in ml) of a coffee machine program." + "description": "Describes the amount of water (in ml) used in a coffee machine program." }, "consumer_products_coffee_maker_option_coffee_temperature": { "name": "Coffee Temperature", - "description": "Describes the coffee temperature of a coffee machine program." + "description": "Describes the coffee temperature used in a coffee machine program." }, "consumer_products_coffee_maker_option_bean_container": { "name": "Bean container", @@ -541,7 +541,7 @@ }, "consumer_products_coffee_maker_option_coffee_milk_ratio": { "name": "Coffee milk ratio", - "description": "Defines the milk amount." + "description": "Defines the amount of milk." }, "consumer_products_coffee_maker_option_hot_water_temperature": { "name": "Hot water temperature", @@ -557,7 +557,7 @@ }, "dishcare_dishwasher_option_brilliance_dry": { "name": "Brilliance dry", - "description": "Defines if the program sequence is optimized with special drying cycle ensures more shine on glasses and plastic items." + "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items." }, "dishcare_dishwasher_option_vario_speed_plus": { "name": "Vario speed plus", @@ -569,7 +569,7 @@ }, "dishcare_dishwasher_option_half_load": { "name": "Half load", - "description": "Defines if economical cleaning is enabled for smaller loads which reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." + "description": "Defines if economical cleaning is enabled for smaller loads. This reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets." }, "dishcare_dishwasher_option_extra_dry": { "name": "Extra dry", @@ -577,7 +577,7 @@ }, "dishcare_dishwasher_option_hygiene_plus": { "name": "Hygiene plus", - "description": "Defines if the cleaning is done with increased temperatures which ensures maximum hygienic cleanliness for regular use." + "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use." }, "dishcare_dishwasher_option_eco_dry": { "name": "Eco dry", @@ -605,11 +605,11 @@ }, "b_s_h_common_option_duration": { "name": "Duration", - "description": "Defines the run-time of the program. Afterwards the appliance is stopped." + "description": "Defines the run-time of the program. Afterwards, the appliance is stopped." }, "cooking_oven_option_fast_pre_heat": { "name": "Fast pre-heat", - "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal or higher than 100 °C or 212 °F otherwise the fast pre-heat option is not activated." + "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal to or higher than 100 °C or 212 °F. Otherwise, the fast pre-heat option is not activated." }, "cooking_oven_option_warming_level": { "name": "Warming level", From 91ba9b22398459b2fc9b70019d5ce29483191da8 Mon Sep 17 00:00:00 2001 From: Khole <29937485+KJonline@users.noreply.github.com> Date: Sat, 15 Feb 2025 13:13:16 +0000 Subject: [PATCH 50/66] Bump pyhive-integration to 1.0.2 (#138569) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index f68478516ab..712ccf09cae 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -9,5 +9,5 @@ }, "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.1"] + "requirements": ["pyhive-integration==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccc401d4f4c..e284e9ca51f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4831ca47990..dd71eb6a8e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1622,7 +1622,7 @@ pyhaversion==22.8.0 pyheos==1.0.2 # homeassistant.components.hive -pyhive-integration==1.0.1 +pyhive-integration==1.0.2 # homeassistant.components.homematic pyhomematic==0.1.77 From 798d2326ed608da5a4f7d88ec3e4f4077f1f69fa Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:20:51 +1000 Subject: [PATCH 51/66] Bump tesla-fleet-api to v0.9.10 (#138575) bump --- homeassistant/components/tesla_fleet/manifest.json | 2 +- homeassistant/components/teslemetry/manifest.json | 2 +- homeassistant/components/tessie/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 330745316d7..bb8f6041771 100644 --- a/homeassistant/components/tesla_fleet/manifest.json +++ b/homeassistant/components/tesla_fleet/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tesla_fleet", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.8"] + "requirements": ["tesla-fleet-api==0.9.10"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index bfa0d831a16..dfe6d7cb3f9 100644 --- a/homeassistant/components/teslemetry/manifest.json +++ b/homeassistant/components/teslemetry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/teslemetry", "iot_class": "cloud_polling", "loggers": ["tesla-fleet-api"], - "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"] + "requirements": ["tesla-fleet-api==0.9.10", "teslemetry-stream==0.6.10"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index ef4d366c779..d777cf5051e 100644 --- a/homeassistant/components/tessie/manifest.json +++ b/homeassistant/components/tessie/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tessie", "iot_class": "cloud_polling", "loggers": ["tessie", "tesla-fleet-api"], - "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index e284e9ca51f..958b94e1065 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2860,7 +2860,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd71eb6a8e5..03f3ea60307 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2300,7 +2300,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==0.9.8 +tesla-fleet-api==0.9.10 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From cbb0dee911b04dc30d121d5c80d4758b0af04c5d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 08:22:04 -0700 Subject: [PATCH 52/66] Bump pybalboa to 1.1.3 (#138557) --- homeassistant/components/balboa/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json index 61cb5bbbf69..38d32adc4af 100644 --- a/homeassistant/components/balboa/manifest.json +++ b/homeassistant/components/balboa/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/balboa", "iot_class": "local_push", "loggers": ["pybalboa"], - "requirements": ["pybalboa==1.1.2"] + "requirements": ["pybalboa==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 958b94e1065..d3146e55fef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1825,7 +1825,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03f3ea60307..ac941a94b8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1505,7 +1505,7 @@ pyatv==0.16.0 pyaussiebb==0.1.5 # homeassistant.components.balboa -pybalboa==1.1.2 +pybalboa==1.1.3 # homeassistant.components.blackbird pyblackbird==0.6 From 08f6e9cd12232dda3273a9b6eed986abc3ffcf70 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:24:43 +0100 Subject: [PATCH 53/66] Bump PyViCare to 2.43.0 (#138564) * Bump PyViCare to 2.42.1 * Bump PyViCare to 2.43.0 --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 96935ba4ba7..a5718962f55 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.42.1"] + "requirements": ["PyViCare==2.43.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d3146e55fef..d48ed91eaae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac941a94b8a..0c9f65ab481 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.5 # homeassistant.components.vicare -PyViCare==2.42.1 +PyViCare==2.43.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From c89d8edb3cf9ed5928af2b9c84a16a2c70bc9c68 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 16 Feb 2025 01:27:29 +1000 Subject: [PATCH 54/66] Remove dynamic rate limits from Tesla Fleet (#138576) * remove * TEsts --- .../components/tesla_fleet/coordinator.py | 22 +++++-------------- tests/components/tesla_fleet/test_init.py | 18 ++++++--------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 129f460ff90..128c15068f6 100644 --- a/homeassistant/components/tesla_fleet/coordinator.py +++ b/homeassistant/components/tesla_fleet/coordinator.py @@ -17,7 +17,6 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, VehicleOffline, ) -from tesla_fleet_api.ratecalculator import RateCalculator from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -66,7 +65,6 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): updated_once: bool pre2021: bool last_active: datetime - rate: RateCalculator def __init__( self, @@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.data = flatten(product) self.updated_once = False self.last_active = datetime.now() - self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5) async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using TeslaFleet API.""" try: - # Check if the vehicle is awake using a non-rate limited API call - if self.data["state"] != TeslaFleetState.ONLINE: - response = await self.api.vehicle() - self.data["state"] = response["response"]["state"] + # Check if the vehicle is awake using a free API call + response = await self.api.vehicle() + self.data["state"] = response["response"]["state"] if self.data["state"] != TeslaFleetState.ONLINE: return self.data - # This is a rated limited API call - self.rate.consume() response = await self.api.vehicle_data(endpoints=ENDPOINTS) data = response["response"] except VehicleOffline: self.data["state"] = TeslaFleetState.ASLEEP return self.data - except RateLimited as e: + except RateLimited: LOGGER.warning( - "%s rate limited, will retry in %s seconds", + "%s rate limited, will skip refresh", self.name, - e.data.get("after"), ) - if "after" in e.data: - self.update_interval = timedelta(seconds=int(e.data["after"])) return self.data except (InvalidToken, OAuthExpired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except TeslaFleetError as e: raise UpdateFailed(e.message) from e - # Calculate ideal refresh interval - self.update_interval = timedelta(seconds=self.rate.calculate()) + self.update_interval = VEHICLE_INTERVAL self.updated_once = True diff --git a/tests/components/tesla_fleet/test_init.py b/tests/components/tesla_fleet/test_init.py index 2162226efb0..ff103ce03c2 100644 --- a/tests/components/tesla_fleet/test_init.py +++ b/tests/components/tesla_fleet/test_init.py @@ -156,14 +156,15 @@ async def test_vehicle_refresh_offline( mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() - # Then the vehicle goes offline + # Then the vehicle goes offline despite saying its online mock_vehicle_data.side_effect = VehicleOffline freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - mock_vehicle_state.assert_not_called() + mock_vehicle_state.assert_called_once() mock_vehicle_data.assert_called_once() + mock_vehicle_state.reset_mock() mock_vehicle_data.reset_mock() # And stays offline @@ -212,20 +213,15 @@ async def test_vehicle_refresh_ratelimited( assert (state := hass.states.get("sensor.test_battery_level")) assert state.state == "unknown" - assert mock_vehicle_data.call_count == 1 + + mock_vehicle_data.reset_mock() freezer.tick(VEHICLE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - # Should not call for another 10 seconds - assert mock_vehicle_data.call_count == 1 - - freezer.tick(VEHICLE_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert mock_vehicle_data.call_count == 2 + assert (state := hass.states.get("sensor.test_battery_level")) + assert state.state == "unknown" async def test_vehicle_sleep( From 05696b5528f2d42e4dbdd696a07081eac2509675 Mon Sep 17 00:00:00 2001 From: Markus Jacobsen Date: Sat, 15 Feb 2025 16:28:10 +0100 Subject: [PATCH 55/66] Add Event entity states to diagnostics for Bang & Olufsen (#135859) Add diagnostics for event buttons --- .../components/bang_olufsen/diagnostics.py | 18 ++++++++++++++++-- .../snapshots/test_diagnostics.ambr | 16 ++++++++++++++++ .../bang_olufsen/test_diagnostics.py | 8 ++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index bf7b06e694a..3835de7c551 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import BangOlufsenConfigEntry -from .const import DOMAIN +from .const import DEVICE_BUTTONS, DOMAIN async def async_get_config_entry_diagnostics( @@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics( if TYPE_CHECKING: assert config_entry.unique_id - # Add media_player entity's state entity_registry = er.async_get(hass) + + # Add media_player entity's state if entity_id := entity_registry.async_get_entity_id( MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id ): @@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics( state_dict.pop("context") data["media_player"] = state_dict + # Add button Event entity states (if enabled) + for device_button in DEVICE_BUTTONS: + if entity_id := entity_registry.async_get_entity_id( + EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}" + ): + if state := hass.states.get(entity_id): + state_dict = dict(state.as_dict()) + + # Remove context as it is not relevant + state_dict.pop("context") + data[f"{device_button}_event"] = state_dict + return data diff --git a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr index d7f9a045921..bc51f89f96d 100644 --- a/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr +++ b/tests/components/bang_olufsen/snapshots/test_diagnostics.ambr @@ -1,6 +1,22 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics dict({ + 'PlayPause_event': dict({ + 'attributes': dict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'short_press_release', + 'long_press_timeout', + 'long_press_release', + 'very_long_press_timeout', + 'very_long_press_release', + ]), + 'friendly_name': 'Living room Balance Play / Pause', + }), + 'entity_id': 'event.beosound_balance_11111111_play_pause', + 'state': 'unknown', + }), 'config_entry': dict({ 'data': dict({ 'host': '192.168.0.1', diff --git a/tests/components/bang_olufsen/test_diagnostics.py b/tests/components/bang_olufsen/test_diagnostics.py index 7c99648ace4..a9415a222a8 100644 --- a/tests/components/bang_olufsen/test_diagnostics.py +++ b/tests/components/bang_olufsen/test_diagnostics.py @@ -6,6 +6,9 @@ from syrupy import SnapshotAssertion from syrupy.filters import props from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from .const import TEST_BUTTON_EVENT_ENTITY_ID from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -14,6 +17,7 @@ from tests.typing import ClientSessionGenerator async def test_async_get_config_entry_diagnostics( hass: HomeAssistant, + entity_registry: EntityRegistry, hass_client: ClientSessionGenerator, mock_config_entry: MockConfigEntry, mock_mozart_client: AsyncMock, @@ -23,6 +27,10 @@ async def test_async_get_config_entry_diagnostics( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) + # Enable an Event entity + entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None) + hass.config_entries.async_schedule_reload(mock_config_entry.entry_id) + result = await get_diagnostics_for_config_entry( hass, hass_client, mock_config_entry ) From 482df7408a047954a559996029f8ec768a160cd9 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sat, 15 Feb 2025 16:29:09 +0100 Subject: [PATCH 56/66] Provide part of uuid when requesting token for HomeWizard v2 API (#138586) --- homeassistant/components/homewizard/config_flow.py | 14 ++++++++++---- homeassistant/components/homewizard/repairs.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 6bcc51f939e..68dc54aef0e 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -23,8 +23,10 @@ import voluptuous as vol from homeassistant.components import onboarding from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import AbortFlow from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import instance_id from homeassistant.helpers.selector import TextSelector from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) errors: dict[str, str] | None = None if token is None: @@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] | None = None - token = await async_request_token(self.ip_address) + token = await async_request_token(self.hass, self.ip_address) if user_input is not None: if token is None: @@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device await energy_api.close() -async def async_request_token(ip_address: str) -> str | None: +async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None: """Try to request a token from the device. This method is used to request a token from the device, @@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None: api = HomeWizardEnergyV2(ip_address) + # Get a part of the unique id to make the token unique + # This is to prevent token conflicts when multiple HA instances are used + uuid = await instance_id.async_get(hass) + try: - return await api.get_token("home-assistant") + return await api.get_token(f"home-assistant#{uuid[:6]}") except DisabledError: return None finally: diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py index 4c9a03b493f..60790202032 100644 --- a/homeassistant/components/homewizard/repairs.py +++ b/homeassistant/components/homewizard/repairs.py @@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow): # Tell device we want a token, user must now press the button within 30 seconds # The first attempt will always fail, but this opens the window to press the button - token = await async_request_token(ip_address) + token = await async_request_token(self.hass, ip_address) errors: dict[str, str] | None = None if token is None: From 78c4d815cea9ff532c9de71cf9bec163d4aad0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 15 Feb 2025 20:10:27 +0100 Subject: [PATCH 57/66] Fix home connect coffe-milk ratio option (#138593) * Fix home connect milk ratio option * Use enumeration instead of number selector for coffee-milk ratio --- .../components/home_connect/__init__.py | 1 - .../components/home_connect/const.py | 25 ++++++++++++++++++ .../components/home_connect/services.yaml | 26 ++++++++++++++----- .../components/home_connect/strings.json | 19 ++++++++++++++ .../home_connect/snapshots/test_init.ambr | 2 +- tests/components/home_connect/test_init.py | 2 +- 6 files changed, 66 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 59a33f01bcb..01eb6e8fbea 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -76,7 +76,6 @@ PROGRAM_OPTIONS = { for key, value in { OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, - OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO: int, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool, OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool, diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 0ec7d3a2629..3a22297ebee 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -157,6 +157,27 @@ FLOW_RATE_OPTIONS = { ) } +COFFEE_MILK_RATIO_OPTIONS = { + bsh_key_to_translation_key(option): option + for option in ( + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent", + "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent", + ) +} + HOT_WATER_TEMPERATURE_OPTIONS = { bsh_key_to_translation_key(option): option for option in ( @@ -300,6 +321,10 @@ PROGRAM_ENUM_OPTIONS = { BEAN_CONTAINER_OPTIONS, ), (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS), + ( + OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO, + COFFEE_MILK_RATIO_OPTIONS, + ), ( OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE, HOT_WATER_TEMPERATURE_OPTIONS, diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 29ca3da15fc..50e50afd598 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -328,14 +328,28 @@ set_program_and_options: selector: boolean: consumer_products_coffee_maker_option_coffee_milk_ratio: - example: 50 + example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent required: false selector: - number: - unit_of_measurement: "%" - step: 10 - min: 10 - max: 90 + select: + mode: dropdown + translation_key: coffee_milk_ratio + options: + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent + - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent consumer_products_coffee_maker_option_hot_water_temperature: example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c required: false diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 8bee37796ad..3ffd84e61b2 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -344,6 +344,25 @@ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense plus" } }, + "coffee_milk_ratio": { + "options": { + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "10%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "20%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "25%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "30%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "40%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "50%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "55%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "60%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "65%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "67%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "70%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "75%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "80%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "85%", + "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "90%" + } + }, "hot_water_temperature": { "options": { "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea", diff --git a/tests/components/home_connect/snapshots/test_init.ambr b/tests/components/home_connect/snapshots/test_init.ambr index 581eca66cb8..709621aaefb 100644 --- a/tests/components/home_connect/snapshots/test_init.ambr +++ b/tests/components/home_connect/snapshots/test_init.ambr @@ -50,7 +50,7 @@ 'key': , 'name': None, 'unit': None, - 'value': 60, + 'value': 'ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent', }), ]), }), diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index e7380d0e255..9e514824147 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -173,7 +173,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "service_data": { "device_id": "DEVICE_ID", "affects_to": "active_program", - "consumer_products_coffee_maker_option_coffee_milk_ratio": 60, + "consumer_products_coffee_maker_option_coffee_milk_ratio": "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent", }, "blocking": True, }, From 78337a6846eaeb042d80b89150dd3827943d921e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 15 Feb 2025 20:16:07 +0100 Subject: [PATCH 58/66] Disable zwave_js testing resetting the controller (#138595) * Improve zwave_js test of resetting the controller * Disable the test --- tests/components/zwave_js/test_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index a3f70e92dcf..6f341f8f77b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4930,6 +4930,9 @@ async def test_subscribe_node_statistics( assert msg["error"]["code"] == ERR_NOT_LOADED +@pytest.mark.skip( + reason="The test needs to be updated to reflect what happens when resetting the controller" +) async def test_hard_reset_controller( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 0a78f2725d2ad99d9280cc8bb0308281768ece43 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sat, 15 Feb 2025 12:20:33 -0700 Subject: [PATCH 59/66] Add switch to toggle filter cycle 2 on balboa spas (#138605) --- homeassistant/components/balboa/__init__.py | 1 + homeassistant/components/balboa/strings.json | 5 ++ homeassistant/components/balboa/switch.py | 48 ++++++++++++++++ tests/components/balboa/conftest.py | 1 + .../balboa/snapshots/test_switch.ambr | 48 ++++++++++++++++ tests/components/balboa/test_switch.py | 55 +++++++++++++++++++ 6 files changed, 158 insertions(+) create mode 100644 homeassistant/components/balboa/switch.py create mode 100644 tests/components/balboa/snapshots/test_switch.ambr create mode 100644 tests/components/balboa/test_switch.py diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 78bf6f7cda7..207826d136e 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SELECT, + Platform.SWITCH, Platform.TIME, ] diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json index 0262f26f4bd..9779984b182 100644 --- a/homeassistant/components/balboa/strings.json +++ b/homeassistant/components/balboa/strings.json @@ -79,6 +79,11 @@ } } }, + "switch": { + "filter_cycle_2_enabled": { + "name": "Filter cycle 2 enabled" + } + }, "time": { "filter_cycle_start": { "name": "Filter cycle {index} start" diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py new file mode 100644 index 00000000000..c8c947f499d --- /dev/null +++ b/homeassistant/components/balboa/switch.py @@ -0,0 +1,48 @@ +"""Support for Balboa switches.""" + +from __future__ import annotations + +from typing import Any + +from pybalboa import SpaClient + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import BalboaConfigEntry +from .entity import BalboaEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BalboaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the spa's switches.""" + spa = entry.runtime_data + async_add_entities([BalboaSwitchEntity(spa)]) + + +class BalboaSwitchEntity(BalboaEntity, SwitchEntity): + """Representation of a Balboa switch entity.""" + + def __init__(self, spa: SpaClient) -> None: + """Initialize a Balboa switch entity.""" + super().__init__(spa, "filter_cycle_2_enabled") + self._attr_entity_category = EntityCategory.CONFIG + self._attr_translation_key = "filter_cycle_2_enabled" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._client.filter_cycle_2_enabled + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + await self._client.configure_filter_cycle(2, enabled=True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + await self._client.configure_filter_cycle(2, enabled=False) diff --git a/tests/components/balboa/conftest.py b/tests/components/balboa/conftest.py index 3a3561f15cf..90f8fdc3d6e 100644 --- a/tests/components/balboa/conftest.py +++ b/tests/components/balboa/conftest.py @@ -52,6 +52,7 @@ def client_fixture() -> Generator[MagicMock]: client.filter_cycle_1_start = time(8, 0) client.filter_cycle_1_end = time(9, 0) client.filter_cycle_2_running = False + client.filter_cycle_2_enabled = True client.filter_cycle_2_start = time(19, 0) client.filter_cycle_2_end = time(21, 30) client.temperature_unit = 1 diff --git a/tests/components/balboa/snapshots/test_switch.ambr b/tests/components/balboa/snapshots/test_switch.ambr new file mode 100644 index 00000000000..ad63fcdf387 --- /dev/null +++ b/tests/components/balboa/snapshots/test_switch.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Filter cycle 2 enabled', + 'platform': 'balboa', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'filter_cycle_2_enabled', + 'unique_id': 'FakeSpa-filter_cycle_2_enabled-c0ffee', + 'unit_of_measurement': None, + }) +# --- +# name: test_switches[switch.fakespa_filter_cycle_2_enabled-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'FakeSpa Filter cycle 2 enabled', + }), + 'context': , + 'entity_id': 'switch.fakespa_filter_cycle_2_enabled', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/balboa/test_switch.py b/tests/components/balboa/test_switch.py new file mode 100644 index 00000000000..4b6bae172f4 --- /dev/null +++ b/tests/components/balboa/test_switch.py @@ -0,0 +1,55 @@ +"""Tests of the switches of the balboa integration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import snapshot_platform +from tests.components.switch import common + +ENTITY_SWITCH = "switch.fakespa_filter_cycle_2_enabled" + + +async def test_switches( + hass: HomeAssistant, + client: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test spa switches.""" + with patch("homeassistant.components.balboa.PLATFORMS", [Platform.SWITCH]): + entry = await init_integration(hass) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_switch(hass: HomeAssistant, client: MagicMock) -> None: + """Test spa filter cycle enabled switch.""" + await init_integration(hass) + + # check if the initial state is on + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_ON + + # test calling turn off + await common.async_turn_off(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=False) + + setattr(client, "filter_cycle_2_enabled", False) + client.emit("") + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_SWITCH) + assert state.state == STATE_OFF + + # test calling turn on + await common.async_turn_on(hass, ENTITY_SWITCH) + client.configure_filter_cycle.assert_called_with(2, enabled=True) From 827865a1b9a7fbdf7315fa60fb81a2e8f3877b5a Mon Sep 17 00:00:00 2001 From: CodingSquirrel <13072675+CodingSquirrel@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:36:54 -0500 Subject: [PATCH 60/66] Bump pyeconet to 0.1.28 (#138610) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 86e3b3527f0..bc7505740d7 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.27"] + "requirements": ["pyeconet==0.1.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index d48ed91eaae..236eb447c8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1912,7 +1912,7 @@ pyebox==1.1.4 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0c9f65ab481..a87d322f6a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1559,7 +1559,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.4.0 # homeassistant.components.econet -pyeconet==0.1.27 +pyeconet==0.1.28 # homeassistant.components.ista_ecotrend pyecotrend-ista==3.3.1 From 6059446ae362311878b7663fcb53329f77d401c0 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 15 Feb 2025 21:39:06 +0100 Subject: [PATCH 61/66] Bump plugwise to v1.7.2 (#138613) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 983ff10b0a6..87878980f2d 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -8,6 +8,6 @@ "iot_class": "local_polling", "loggers": ["plugwise"], "quality_scale": "platinum", - "requirements": ["plugwise==1.7.1"], + "requirements": ["plugwise==1.7.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 236eb447c8f..b7081812b44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1663,7 +1663,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a87d322f6a1..4c995a6bead 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.1 +plugwise==1.7.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From fdaa640c8ec41589eacf292bfd7335bbdd12d61e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 15 Feb 2025 21:44:59 +0100 Subject: [PATCH 62/66] Add issues for data cap to onedrive (#138411) * Add issues for data cap to onedrive * brackets * Fix double space Co-authored-by: Daniel O'Connor --------- Co-authored-by: Daniel O'Connor --- .../components/onedrive/coordinator.py | 25 ++++++++++++ .../components/onedrive/strings.json | 10 +++++ tests/components/onedrive/test_init.py | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index cc759437c07..7b2dbaab87a 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -8,12 +8,14 @@ from datetime import timedelta import logging from onedrive_personal_sdk import OneDriveClient +from onedrive_personal_sdk.const import DriveState from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException from onedrive_personal_sdk.models.items import Drive from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -67,4 +69,27 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err + + # create an issue if the drive is almost full + if drive.quota and (state := drive.quota.state) in ( + DriveState.CRITICAL, + DriveState.EXCEEDED, + ): + key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full" + ir.async_create_issue( + self.hass, + DOMAIN, + key, + is_fixable=False, + severity=( + ir.IssueSeverity.ERROR + if state is DriveState.EXCEEDED + else ir.IssueSeverity.WARNING + ), + translation_key=key, + translation_placeholders={ + "total": str(drive.quota.total), + "used": str(drive.quota.used), + }, + ) return drive diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index c3087d435b8..3a9f6d06594 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -29,6 +29,16 @@ "default": "[%key:common::config_flow::create_entry::authenticated%]" } }, + "issues": { + "drive_full": { + "title": "OneDrive data cap exceeded", + "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + }, + "drive_almost_full": { + "title": "OneDrive near data cap", + "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GB of {total} GB." + } + }, "exceptions": { "authentication_failed": { "message": "Authentication failed" diff --git a/tests/components/onedrive/test_init.py b/tests/components/onedrive/test_init.py index 65c3e62629c..b4ec138ebf4 100644 --- a/tests/components/onedrive/test_init.py +++ b/tests/components/onedrive/test_init.py @@ -1,9 +1,11 @@ """Test the OneDrive setup.""" +from copy import deepcopy from html import escape from json import dumps from unittest.mock import MagicMock +from onedrive_personal_sdk.const import DriveState from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException import pytest from syrupy import SnapshotAssertion @@ -11,7 +13,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.onedrive.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from . import setup_integration from .const import BACKUP_METADATA, MOCK_BACKUP_FILE, MOCK_DRIVE @@ -131,3 +133,38 @@ async def test_device( device = device_registry.async_get_device({(DOMAIN, MOCK_DRIVE.id)}) assert device assert device == snapshot + + +@pytest.mark.parametrize( + ( + "drive_state", + "issue_key", + "issue_exists", + ), + [ + (DriveState.NORMAL, "drive_full", False), + (DriveState.NORMAL, "drive_almost_full", False), + (DriveState.CRITICAL, "drive_almost_full", True), + (DriveState.CRITICAL, "drive_full", False), + (DriveState.EXCEEDED, "drive_almost_full", False), + (DriveState.EXCEEDED, "drive_full", True), + ], +) +async def test_data_cap_issues( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, + drive_state: DriveState, + issue_key: str, + issue_exists: bool, +) -> None: + """Make sure we get issues for high data usage.""" + mock_drive = deepcopy(MOCK_DRIVE) + assert mock_drive.quota + mock_drive.quota.state = drive_state + mock_onedrive_client.get_drive.return_value = mock_drive + await setup_integration(hass, mock_config_entry) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, issue_key) + assert (issue is not None) == issue_exists From a3eb73cfcc8ada5175a042f12e647b7bf8f50124 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:46:00 +0100 Subject: [PATCH 63/66] Replace alarm action descriptions with wording from online docs (#138608) --- .../components/alarm_control_panel/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 5f718280566..ed02b2d0ee8 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -90,7 +90,7 @@ }, "alarm_arm_home": { "name": "Arm home", - "description": "Sets the alarm to: _armed, but someone is home_.", + "description": "Arms the alarm in the home mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -100,7 +100,7 @@ }, "alarm_arm_away": { "name": "Arm away", - "description": "Sets the alarm to: _armed, no one home_.", + "description": "Arms the alarm in the away mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -110,7 +110,7 @@ }, "alarm_arm_night": { "name": "Arm night", - "description": "Sets the alarm to: _armed for the night_.", + "description": "Arms the alarm in the night mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -120,7 +120,7 @@ }, "alarm_arm_vacation": { "name": "Arm vacation", - "description": "Sets the alarm to: _armed for vacation_.", + "description": "Arms the alarm in the vacation mode.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", @@ -130,7 +130,7 @@ }, "alarm_trigger": { "name": "Trigger", - "description": "Trigger the alarm manually.", + "description": "Triggers the alarm manually.", "fields": { "code": { "name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]", From d435f7be0924c12685d3030829c61cf160f986e5 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 15 Feb 2025 15:50:52 -0500 Subject: [PATCH 64/66] Update integrations screenshot in README (#138555) --- .github/assets/screenshot-integrations.png | Bin 66219 -> 101607 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png index 8d71bf538d6c775dc4c09447b265497d8cf83a47..abbc0f76ff0288adc024528eb3ae0ef2bae41156 100644 GIT binary patch literal 101607 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 z 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
  • 8Ml((_4P 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 literal 66219 zcmdSBbyQqWwaXDl~G15YF*{Qk#ZL~O;kKajgbwX1~upuzlvhM5rY;)N`xyRWbB z_m=kdcDt>knymSNhaktDtm>n3nu8qc?Ja1Kb!mHr;eJkqZ&yW4*M8qSoEMm3bij{n zWI9NS5KW3Of|(HUAJ+(<5M`Ajb|-P9k&@*5ELau3wWsqv#n%#+ULPn*R0OBI{AHp_ zfu*2`uoj7?6XJG=;w~md12G+fOU}Z6d@Z7;)}oPevz&>?(nI`W|Fhca8AjMsPB5K7 z$e|t>9T{QozTaO&|3lS|Rpnc|(7DA-LJ}R&QC70&d%fM0-V&@*Q&}157kim*`*M6Q zWW#EJ3q_K|404MRT3l6(!~%#SI}hyNV;Q^x>bVjezv#2ikDtM6D5n$5`wSim=;;lC z3nlcR0!FDJgNJ;tqPH)o3!yXb2Hzp1AUPs0e_3Sz^u$(w(BpfNPKbwPid9NaJ)QVN z{)Nbl_$T!qu~w|Z%ag5!;D?~)&bNYx%Z)08$s~DL_A;c|a5LZ`(tI!c2GnGjWEH|F z^Sz#pykbFo#aa@pn~J9rF2Ek#otx8c^MZe(W9E28JcZ7OQoUDTbr&6pCMYWtrl(5L zXSYCjfEgW+Cb;T_cDJ|ATm+q=9XDkhUza&N<`;J>`RthaN6zHur1fqD#AO-Y&$NzQNJrgvYNQl)I z-B@ ztn-|9Vsb=y7y@iqjR~`VueS0|eTM_vrbq;=Rj6(rxqwFOo?1Bj;>A~L_$68`uoAVe*rsCBLA^Lr*vq+P z`d#*%XU?Du~3hV%Iz87o5|rPW5wGoN0n zFf%fS>sA}eIWE=|17e&h87y(QR2Tl~(xday zu8}W&c6YHcJC!d3udMAV;Oe>zizJ{`f2o|DC%6%ibbSVwdCsbx2g(ijwA|nzR#M_lB_KveJja^o5Na+|_&5U-f4jF-XC-wLT22+xky!Ha zMUD|vn8^O!IdL3xiFJqzQ{sgwvyVv<9{ZI z@OmK}PQheaD^X#u>*lCXfi^H1XholsZ}B-eI3#nK zb>)451>Zv(JB?dC_OFG+8LZ{rKdUR|FmCzG%1`&Ov4`^I?w;~$Tf-VJHLmw;YQ}l!B3s(`lT)<{V zRmVG0bC`C_u?FKEZUnz_jzZ@5=5Z-1F0Mof2i-=@hdP%GsH9GxZnHVu#8WT$?uoqw zffT%ITz0UaT&Aieymk{62MSkgdexd2PcASP3)6^M_7b>^6$|Q2oRv)QjS9qJl+s2_efTOX{TPnLW$$Gj5QyHVTu zmqMpXUsEp0a(l(KyEvX&bUA+LcVke)Y5-#VtbOAyzNYK?&Gm8cgG%$dzhj7zOY6I! z^GdVnjfZ>R6q;S&_5IrZE<3{84r;V+jyn+Q*B9LnYTftewJ8~Wf9PilIFDL)X|7i` z?&bANyi-`iNAD(s?`66l?vCfVG4Lpg&cgPdGdKirDiS1Fw_Om3pAp_|5(I0&HG&X3 z#GLMeWCVn8-eq&Q2T+wvT;4GG; zPCCOgOE=;*xm& zpvv20XPd3t;2P~5`8XQL&ELb1OR9apsU2|Wb{eI`$+v@!C7O`hE)cni`UCcZy0eizM^pfhD75-uUsdxg=-SY zS3$BLO#R_6Z6d)@`_bOHzPXChuQh0OsT{T7gSG&6a@>?>g zGrXa2?${z14{X2 zo0_-`mAHtlH&gOxgN7lIvg@nrRL<)%q2wGbZY1Y@k<=;h*k+q0co_^>P4(K(D8wB>wMZFrYH+8Y6Kg;IiL`)u*UTdnIM>`=rYl+S4{ zcENQ?vD~6SVEOvtb>{IqRF_`Y!CF|_(KYQHrqa-LqGXOwk-@Pa?~vw zu#D?G4L43VZl({HLy{;v4!@V`&UPgq4DdRI3jxPU+M+#Bq5EB93KAsawqz@gjjTM zI6rqrO9p2qHAyti0g2{ynQ3Pm1yf{+d4YLYrEaxyqnf=ya+~D0N znV?HH&n~5a6Zq(3WGE`eOavBj>`l5%6uyC^ydK$w98TnzCd?rhA272cuMrKuWet0T z_?Nu6i`-{2{F(U^Y8M8Jq6;AQx2YT^@{Exm^Q5q_8r*tOykQ-*aZ{QHi%SC^NFDmG z&-vbbRitnzUV{?^7fP8ldc4G=6;XBGu=2LeCjGruA4S4ptk@rkTTGuyY1U?4-p0}q zJi0U95VY1tcjl20G;q4j=k(IBh1i*;Q{mHMf|M{u5v;s^$1rF?(G}k^ZVvz zLz1xbfvY>8NzG`6Q$c(`Z(`aY_#%)cWp`A1<8^rCxc%Pe#C<dP5M+5%&(l8Mg^ zwgMt$3b?^P*H55=E;BQ4ZkZ}J`Byhy0{*IOdW&6ai8-O9&D;4v(8+^8a44R(biMNi zgTWR2v9oGYXi=wfq2k;3Dg)0HXoS0TCZJbJGLiA0m(#|MYlyjau!y?|j5c1nj3UKG zWI?4R>?u6+regANSGjhBO##DsoW!J75JL>An7q7*q;$y}qIs)ok_IEWpjiBfujINa zlL5lLxZ4rz9`-;)9A9}FeGMdxeH%;dUlC$h=CCPq^^v=-3_#ruExdR2+P{acjC zs8Hc71etpRv;SJxQy}-jxUi#4CS6r^0poJTS-KMo2#mr7KHhxM5WyC-9y)_%NvBmE zD6M5G+N}tddgd5vF2_4J?2aM(TU@3k2cI=J99Okk4c1xcXYx4|@Rc9;^Fx}hQ38#` zdOM@%#WqD>H@led7v-3rE0pj}gPoW84DHXmO9F8l!ZC#MUe4az+b>iZENvmrkvcM~ zjBDLEXK0psmok2&qwg@VlCfPECzalawLVa}?QK8StR&%ZsCZdZo>Z!l@u_(4u6YA1 zotV!wh4o%_;~A$k-u^-%`E04y#(F1weDQSl=fDTJ%rDnivjQhS=Q_ifP*F=O!i= z#9)w#{v1mwspLI2(J(yB(f}RRePd2eUBqGhOLX2KcYy)V-;fMjRw{uc(rIi7OLxeJ z?%Py>DcD$OgomYAIZbaT-#ZI`Uo@{Yw72IE&4^4y@k5AIgVhjcVTP=i&Hb&McA>3! z3XRP|^`cPT6KVm7V)!NpS?PO8y?Tn|45@y*7PG10>vPI7hx!F%)OVOOKL)%eA?k`uTv63z>H93R&jG4u$LzvtpEh60lWHHh> z%pB&x%gIm#iS0t24(J03SEEUqdesir#ybMZTMcnF46Q%si)LDd@^N$4?nzK-*pr2i zU|D}K2rH?CyBRB|3n9t1GmQ1uDxP@f<)&B~r^%ZtL295QP5OJsn$}2~;9Ga2wqYVep$<#H91I_^PKdPEV1a>;pI#XPP%%L^VPIZv9SC6x01qeaB!(CezePt)we87(3y zGX%d&3th!z6wKbfcK@A2%G}=uf|J9O-G`0SS~m6?h&1O`QWamBHXq|e+ejY08y0)V z8r6GOW&Hhm!f243h)*l){pn(@S#8jzXL}SPP|qneyWrhNhkISW+xmbhah=y^M9RH+(7 zf^f0&898;@!roU1&6?w@7n=oWvdSj!jAhWCs-S>lC^VCf#IL8C4n>{3Bb2uBCBRP>A~)E*Dp=$Q)`{FwXy`L z=fxLiU%WIa>P$)#?M3pBM^ z_1zOyvuD|xqHVzw5Ng8#u=K$~^}Gk_UKv>P_Pbp!9uGwEs9QrUVVGq`Q{#H47tl{;>-yKZpMP#Bd0%2f0jJk z9UN@rpAIS-qGC@c6inE#hPeeT;cpF>;GVxLy-C3EEN-a}zhX1%>flihz8W>t=0oxP zLU z;S>MpF7^d@zuTvJt-C@)KfUHGwHx!jG zr5$~dU;V0qP^9OoC&EhT>24e?6B$hQ&I(2v$>nZLMQ|Q>%iJs1ZE2+yO zXx|xojU_`rh{yArYo<++szaaV&zob1g5d0D&ozeMFy}acU+C7TX5<|LE~gl_){f!4 zcvYfEBw~X#)=FK#YLS6U^Ym*ctNssSWi_m>c=1&F>ozx{b3qCeSr^qD7ye31L8G`< z&$rBk=gND>ydgkTpJx#URxK^X;IGit>QB0b9;8;EU4e2d8a7DpQYQ=+3rYn;?Nc2@ zqnoy5ensx`@H;OklQU|Gfa9w$%Vfejuix>j<91y}8*b9r zx~S-(|MUz_#u#MCMz+w{oh_Nf9lPsov`l{uu@Zy~=%9^Rr>bR31nOp}{~Gw}gqsAv z`A8#9LMBmVT+DR1M7|t?WGo6h>Tjhpi;6eVG3vRQ7P5-|h)2$#aee23I^FV@x~)tM z{5JiSi!x~?RJ!y%=$pCWpKp>q#!pp$`DB8;m@hVhgL}Ymy8Sxm6@g&ojo`#gP&z#s z4;kg3vsnn`uI`T@`u%gnq-5prTJ&CzY_IB%j&tUx=70&jTrQ6yx?AqV_Gj683K`ivk{GW&(;l^AlzI!gz{SUzTM*_Y#+? zKr{VvV#WPV-wJ23iXe9^lV4>G$IDN#{|5~N%DaP)FRsym)QFq-YTCP*>FHT=?L4(T zD5Nm;X8F?7WCcB|)YdzEWUg0?((8T-)CCU3 z!x3ufH}n1EVt+cxG!Sl+qP`LDephYO1b7Opg+K>|z$$dAX_jPXl~N+-p~czi8mnwNq` zTheD*EAOI48!3C|Z1 zQ;H6VCV?XwKWn2fC@ftB*3s%{t4~tCcbK)C7L*ItfO3U1 zHGeOb&z??D>W&g!K}S*WKw1tB)9fKck>K^;Izsb~^C8K>33_Sehdqckr~(<>3ysH&W#8oN6%bqhhkx6(_h!B^VEf5@WUD#}THC*_i2X2V_ zT^@~>?3Owqb7Mf`4YP(#mn zZO1T+27FNyDOY)A|9+-r`{CJ+MI7M_)|$dp#V@GKrd@R5fn3(mwMEfpe8V6P3j{l7 zr%Cyz)47zo(SV(A$*1K4B3mPcE#rQQVp>HU3^l?f?Z1oVA6lYDIff1j#|?{Zw!hoj zy20v?v41{+XVWhx9IXGUo{XgH=T7<9{tKHROY){!P~49gDN6AaD$`~Q_8<@I2-W3~Hmn`*iqn1OHQTKapUmPM1Anq%?oW7x*dVN5O&otzFJd<+$_ef?m z6>YM*M|i#o?J9p>=zQ2-%D$1t?@<_$qf%7_#>a>4^R;Iv%;ZBnNI@;L?5PV?%d_`6LUp8{sb4p-L9D z*#}-xSorwjh|rtRCIl`u7t2rd0V=Jq!w12m;KNJ@8HXoLO5CX8HSceyhX8e$>2#3o z-$A|#;ai3j+637(yO*<9AO89f7tyXlN*y1h?e=ww8#*_G^EQ{jFUV#*n)-2@`em(J zu=^bqKOz=pd!Cu6<4Zm>m-q6FzXj7S#WT~K%G~$|B_HxZ$lAg-rJrFzEs8!iw1{md!SSXN!&?Or(Y8K>}RAi_vqD1iq`2KO=I`kWlTI z72CXtcpI}tR}m?!I`x_vfi{b#4 ze6z%fGo}R`>g@Z3HvB5pr)#^uWRw!6iaxXkyvs9t+oAxLyB0tn{QUufQ_*e34@v)h z*E#=<{%MhGtzLWlwAkit7pu0S_+csFp+T2!YAxuhev6d%9yTL0`}4hs80UT7FN6F}2^ZPc?3#2YBEeZshP;ocpM67oQcgBrSw!PuYT7g`u zaOt|hA@y@UijkgQE`hXJATo)u`I!qjlp|iQ=Nj!kGRyU#di+tnvZ^L4h9K#_guI(Q zbS6LFg9SFi#=d};E=BIOSHrTha~h;2JgPu`M17wB)KWiTQVXPuh(O$IM$u|VA)#uQ zzkWHdQOGEe0U~=!Mgsz)Ogwa?gU$mRmY-JL;_-$K(%@K-aM1+Z?Gc*4GEWAwvJ^`} z!sEm>DU6Ukva>TJYBp5@5$JlV4q*B~xPpB`Ki?jpN#eF5aGwCxwwM7iIW(pyTB=v% zQRqg&3M3qZ;HS8uE6d7cufu{gUG@Jw4?gJ6L86x2IcmuO+0*un(E&HZXiNGWVCIG= zf@8y}97eGvs_ z=C8g$fAW1JATGHm6_PL7Q4)OXk|(yHk(ybmpT?^1CB|zuj<=b{+B1W!Q=L`o+CP5x z_n@}^tJw30zoMA}1I={Q;mSj^K>TkznW9ZCMYqSUHFAMadf)cIhA?pH}__@EoP z#8$QyHzb!kW~y@{i~j$LQjZYcz@E{C$4BL>ZE``9w19~Q#+ zwnS|62ajfC5Vb0B)YwJ03R4k5=IX3?VN~ z(_GH()?AJ?yGg&`ax~hZ0t>p~lg&tDd<3;mPnwH1;1Gvl5+30+ZJG9a(N*+aSl%7_Fk5?y&vaHTVm2?kPz z3`-eYlJ+P4A-E1orwNGqY_@?<9BxmV1)3w?k%v2&3*~bqfAIYEb|+iro#WSE*NS7r zB&7rA@EcX25}eP@4^eW^#ps$4^up-l_=KfiYCARQ$V)oB>dqN3)D*Pl?|)p~>~Qui zaew;0tJ7a=z+-zp$xo5FF%mZ_FdtWRlocK9EZ`E)&}0dd<=51c3xPtxHWm-p5i zxKMVmay;n|E(;anUk@pLVLgko94J@nmw%(iESv7;EMDa(Pc37tuPc=+hJSLtw_qYJ zoSvE~uT`p=&tOJUphV16Vexvbx$h|+SU-D6sezgnA~N{-^*d~xGLIBPO|8Z^&gTuJ z6SRx*jp;2`LvNN{x(gVdB$6EcA?oa{GzP3p=-WgG80*qsIFOUCS`<5_ih$GaXgG}LT6*cN^K z3>rw?V-G+jY+zn2zgjjKlX%KPf65@=)6!fOY!*d2{MDDB9|Gp^^QD=|+vo~wq zOl#f<5_RjI{??D#Cw2!M2`ZAjXZa!pr-*(U|M8-jIq2T=28%@vp9#@0xgL1NfES18E_x7MV4s1k9k4n9qxP`#QxC3MC_TCKwE-af^k7{ zG3@}G3jVRA_`wS(?(Jr;;v+)0xzC)DyrUd5EYHcKU-Yu~{xRPpAW|Na>vLlpiD>#v z-5()j7uY9sgm?P$o(}KS0+ZkZjp>OD^fJgobkd{`%&_CVDRdbxZwUhzggMFy3pK&y zF@RFPO*s@Lipy3*Y3RT}AV;nAL!_`q>1&>{Pa95~N}Vv#q<;AvGO}p-b4rav3jUOs z*L@ixaX!bVar|32HvVn6X4kFpSpGX*syQm_!w_thsEd8;4C zFzg9pN2^;RaNZr*mqBR1b{{i6#k-g-LXjg-Ec33-XV+x!V9g6=yZ98E<1t6;K;hjn zSmED(MPURtklDF?MzQnM;*eHVKK@C7fdQw0@QlUW{`dI1D(@Z|+awe^; znRh71@%`AF%#3rNsUt_v0uw`S#yr0ONoj|-mWjK`n@d`y<6uC5h3pz}jG5NAh9Ez^ zkAMt1zZOjK2amTUKLn{en~2C_O0h56{6ux^U7w*k+2B_9N$tJhU*xf*nTJ7e)(Yc} z5EmJqtfsl!pJw88Gyw<_B8QN=&*%40;Z-*N?kFTV|FC!K0ZhzH>6r*di}uMJo#`du zAG`VKDdkV5>_Y5Ch!^fIEt32(KIUSxB^?rm=$mzomfgJ7q$!ZLKM>+rfc`{;(??uM zi6Zxh>rMU0bdzY2NZ0TZ=wKDeWlVkb>I`i+Mq%o>?SQk9_R8Iu44f|D_1k4Sn0gND z05m$THFHF_Rg@lxYNGt%H;ZwIB6a~Qm>9eSMs@P{BD{;LizE*v@9Uhu*t!YxAk2}K zRo=h=%VH9LxQ$*g$3jjAHDrKAIxd-y*kZw6_@0$nLg-g(V{8l-RQzH1?wrg78%hhb z%a+EdO@**4!-TXbP(A?}l`OqGq{SIyiu%+_v;U>n}O+JqItKrcHSID6%UPHX80jqrq7&O$5$VUsM+e?)kv#Iokt0t)J{gB}^fh zL{HgI?Uji+$eMl*e~)%+@t#QxyB$!uBw4w=znK|P%>XtY2V$w?l^l981ix7lL$3Pc zyuT-1XEij@VUn8%d+x^#VVY`r z&aClD&1In01s9%AykmA3U1EO9D+0z<(kauB;y(g&lSN8zy^l^T(I4!_kL&!wn#=BW-u+aBLhi^ zFRNn3u)KzhMIZ~&`k-D*UP3B) zDgT^cXu&apy|B5^P5s2wI@$Fnxqjgn9A1Lc(5WR0I8ib1>M7KYODGPoPOLpTNb%Xs z6s#1p9Zt4;etu#m*nPh^*m8Hx6IwcL&XYh}Wngt&O4 zs>1R&6G<$!36F_0QJ>#vDeT)>Y2gY>i&ZABH&6D_XL?@bd>--1r1tVi=ZkMF)Zo;m z_{eu1aq*r0ON2dk+J|6^p7%iqM!A?t_03M-Ns<@QC|J^^<0CBAlX&A?V1)E<=G!{Y z*Kla>hoBZh35Q%SZrM=!isJ)jyB67RU>m28#F>I_j=TEkJ51wuE!n*oDWNEqp z#Ydg2{8D#RnPITo3G*I8e(36908zeCr@ym#=S%jh5Z?vi-S8i`B>ItXqlbvoiupH{ z0qQbE;Z~N+gfej)MvZ#?dHOacknnfhS^SRGV>Cy{A;{tV>OKl{I9wWoqW6F9rJK#V zfp}AT9R)zTeH{?Pf}#M4+ZQ`Z(QH7mSs|WAr~eh1>Gf|!?VO4ecPn{iWT|~^N3SuB z?7P8Oa@usvJDFh*04nxJ#rA|8@=mA5Sj+oJ@q_P}8jL5-=V)1tv?`vG3E39)gDR)~ z=ji2z!)MW)n@Zr0t+-K{W#^Tq%J^K`SuVXJQkz@zzY~TVKZ(B^aGAo zxkd9^H%_XU{$ICsRv>2Ha)l7^BpO=`Btyi+1T-@6)s6k)2cJy~KD$B47dLz%-ws)_ zq;u}7(nX93Am8{@q}E%f+TTRP!Fh4i8bAd8H_YTym7%D3pEMhL!{#GG{Qx4atamZe zQL#GB?OyP51|^+}Ic(g}&3QKcCo9f z3bRW29T&^xK%=R5201trI7#1#ki&YNR$HFvRA`HvMDQ2vc{#(Yzaj3!-0>pNImC6!%|i)RO+kc@YMLDWkleq9mlKB3PP zx@)=_v^lKYT$TesN*|%20KAb9XdM-SR?qVA(p+__mw;A6xswYohdm@TMAL$?&)gX# zn+;YN)}{%^_n(Ayj)Vjn>=_x6tDCcn_#UYPFfvRYbzD`nF|>91A@C< z`1=H6K5s+=i+erAr~U&CbDHl(8J7z@!uk!oGsVQ;!p|v>?Slz&q;i?rU$67hf(g^WMS#XT;F3ZMAYxORfVWB>n<08BuBLxZI3)i=d_>Y=~ON<&pta6vzvq| zgrno>?G7R$6EGGr!=v}e7(~pBb`7ii$e+hhi2G+}UQ&%jq=#gDeT&R}fr%ImVm8oK z6N;+)%5B)-q~f_VriDmaWsqUwG`zq=6g}33NyfiYv9A)W+I19>dlob~t<}U@wA`@m zDE;4s#{It#ApHNtD~){Lad1U>-m=g7=JU7DTPY#Fr#@X2!M`zP!=xkD$MseywtSJfAoT{cvlD-B^3p6%B3_YlLQ}e-+FUm z3m(ML?675 zxQ>GO7u!+@g?^y+Y9r$gTsb5XSx~$sG^Csr43^@T7+EtCTmTsdEX-byDgV$z`k=2< z)?~7=`;8bZQb8a7!_N$Jlf@m#-C_+{z|PEAnmlJumqd)x4qC&0Jx3G;m0umVuMyse zNNq?|uRBrT)J#Uag0@LWQxD0dwT5(uVG#7*0)t2oYPl&g&( z1G$S_HY-@b;j2FvJbfZ4H5aBgM>jWg-c)L|`G}R!RE=F_Eqt~8!;f8)Eo(jjZ*P9h zCg(fPO-?g7JyewXW;==jxf&VaMX}A2D_0Udfn$VDQ8$l%?0thjqmAq5)vt#tL)(>N z>|#3Zf0fU+FK`BmdCn1X(2Sw8?a^lv!R1ujPim-Fc)O_*O((ZsaHWHchPD^8YEWy! z#|FRY2_9v7{6;L6-?&qG*B(60^%R2kin({T7jthfHfM}3ithZ$oaJ{s9y@EAD^?rX z`DZ?su(O*zFu8Av{8BxSKVH&@l{sfD&ENLcwn^42PZs}tN%X`($%G(-v3sux! zpYdc0U2Qt(4#uW&5ziG6l6ROXPc1T1wM#g}9Yp(Eo(+@)Vt}AeHMvnfPBrd*Wlsu z$%z5{doGh(7yS(LWhOO+&d_W(<+AYLKMKb_kDUPT~fdO z#sTyGw@wpq8mYlr#>8~Bv%BL*X=4ZysR??`aISi{?a=wbqNlJo7QmD6$b+8hsFx`H zDO|fuA4?iJuJy8e`Gy%$s8{x(W&HV@dzA79)>#50}<8h0>q_*5Q@Eid?S%{AVG!*vX59v z0B6pgTAJliPm4AaI)6I$`wox^&OL3$lDgv?Zqi!gx-04Tzx~a@&&TV$i*7p7Hnr#WFpz6rnN6}jyQdA7VUbVI6s(~(332=yF7U$cWjFxei2GBW%S|H zlmRM5c}SYsw>)^IO|eeXqgj4`7>ds-yNwQhKPzoQJaU@i%r*-R7;<-7-1QJ|vNEwf z4TGXoaL*hCn&)=AID3=5Y3~j~g-Cqg7y0T=)z&d{4omq(f%tk}vgO2k7HD`^5s1&R zmwvIb(&d)9)zRc5v+Z8QpOQHKkC7e`V{!5}Z;5j8Mmu}!TIqP$ZZ`Qww1pt%%aQiV z&)@az0d2DW%>siq?T43L_ZhQj*DXChc2}t`qz+erQ0;!*9a*#yx~Kwl32(dSTT(*O zHy|>Iru`SeXZL+(sb3-P2bLB(>nHz?=8Cq6I~e}Oh4ILOevvEW;3*W5)&)%Zzc?`x zy=?y)0MwiT+B^kL(*Ny0|4V@QUx)l(={5g3)c>nD417OP3V$j4;a@x;01YpzAkFuf z4=)>=&EU1umItU?EUPE(r5HuMPYWIiWKRr3ZN0W0PgW%MPYww>64F8RHHt$ZKY)w( z&p#gz8X>w<_g}4HbV84}?_Hj$e+0;3iH{r8mjjy>i;0i057EubDjOpM>>a8XuM z<0s+7#0AK3ZgxY7DggKF-)yx=EQ+69ssNGeeGTJYRA3@dSPO0o{kb=Le|Iy#QG$wz zf*SXV%|Cn~F4bN!8f`k^2arZ`#K=VsCZWbk%sapgZYM8I*nvM%_UtBU9=Sj?vV?hf z_GpIO-T?p`km^w!+9LHu_l8=yPzxC8snn_EY+%{w``;27E5HG%38g)+aHh~2kmKA? z2Q2?jRK0KZun-s6*&q%lt_6E6swG1T9dKvFY<|}Lb381jM}FTbpS85tixwhf1(!iA zv@v2gUJWFAFV&8It6b^zClpgrdfC#m5C87n=b)(Y*gGcP>tm4>AB0_~bjiz2gZ}tO zRWR_^8o-q!p13B(!5m@i!DErfh%4piV#7_c2TpKOt7FB3!z$0>_T_{W7h|tg}LUmQCbN`D34LQI7U3jr4g6#-?Y+?>`Sek995DmaT>4I$0oLO)6NM>)8-}FNFBu@8{cq8KQp(+=+`a(F zpI3IJ07;MG-&(TtiW?9$ioEB0=%&otXT$a|Stn_}yscT<8?;OI|I#sg=aI#T+7R~l z(a8Azr!{)TYY9W+dc|fRTp$1;q~v+O6rs2o4>`X5Pfh**kpAkowz}nHq>COG5YTa2 z0rjevz+$lc?~LdFf0oXpm~ZawZ`(F_W02SU)>?#6_)YAMb464Y(my1{Hd!Ty;bcD- zE;aElVb@0)(&Npz`6HBVvjmmP8q+D`<$Lc*v_D_Gxh|Nbxc2E0sStX@%b(E{e$DwE z!Ye>qnlr?yVdx)O_j?r=z_I?r{N#otCc58)i(@&ZS;E(gVz*8XWIY)4lx4mT2$?va z?C!2ibJPmvV@QXC}&5syi3ozb)0wXgq|&g(YY3 zwj}c;;{!k5ga4W^22&xBO>}G-OwPAnFzTgcs4nVd%X2lDN~g%XmRMBcPp2}H#tu!Y z0h{#?Rh0F*C#@xDcmprq>L`&^jeCA9X>wtKiObNaO&F#+c#M#EJ8XOL(oQ~SK zSKJP|3KMA_QJB5nP*^Ehl^s?IQcKe{RcrXk+GZUZ+}XFCxB9ENyW~*oz9fEeK!h{? zVbfQ#0?`ZIyj9($CQv<3c>WlYue!i^IBlkma1`w4dausr-!q-1ozO<|DKqtDfQa>s zZq3fWYx`U>Wwyb|a)fek)=4q{?N`UNn0c6;O@ftOZhS}i?&jq^91k{H8~0xOSJ0cS zGYDpsN{Vm9)A)7ccbYZe@;jc9aF7R4zT~POOSEZurgGWT3J=&+ z3pZ~0^Nc0X-g1Y<_6M*UIt@Y{{A8_U%o8o`CKJD((ZS{ zU}ODjm3tQ8x_aXc4-XICg%NN=IE%6ilikg28*=jLo43U(bKGx!<)Al-v;GYl%O` zeg`1I^iqTasl(UMRxd;{SDZYSdCL*ZVPh&v<;`>gYP}B3@4OK-S^9cX?gpOncNA_m zZ#P}bQE$zo`6FiMgZ<%seKHJnh2uYXy%A>siJ$-=8G^4r6I`Fl143F?5~z zHS8(qK6l#q7rz^=n`BLJQ}{_xtHr-X-x?9IH#NsNIzN>;3|=Qq5{k8EigasR2F zP}Fh&Sg76Uf8HaJUS>0zNBocuFxz*2Xf-VuTofb%9N6r8oc1It-5}P zrGq5N9g-zFsfru!aEhCx=JwE)DShfE*$s>-JV|ZF0x~$qgW`Aw*NK;4+&_JkU3Nzc z$?iFezoolygL{Q-R0Lo1ePAD*hR7nU-ZEcjR>iv((9 z)Z+fhB3^rhV7W_58RLmoDP~82Cq80*zCW)LqrZ{67Sjxse&GUqkHcJ%g3@~voA*!J71Lt(%_N7lK%$h$7(hk z?qJGzH)~zDyyciZ6PfN1e4e(`B?Tv@`GT9`8a%kS!dzUx!7i+LfP_f46?1W+7tJg+ z6Q7R8B_1^(=pq^$a42am9WJuz1< z6D@%ySraW=9`E$_#dI#welxi)XdVw~9^5Oht< z=C_d|3-v>egJ0W*Y#KZ7GqAcAjh<~xT$1|W8L5+l*7a?_ZT2hWq2Orfd*YIbJBmug z@f<5{G3Q;%^ouFwQRb?)<>z+(W6uHDW8Qd%vYAiZ;C!+L`O^Vxwj3*l>*1@#>6j;r z(@Gz-aDpMF924xJ-o_@4Js5fV zG@1TZEaLZvZoX0v;=M!mS|sSUh1oofnixyC^@(jS@{@e-LGH-76?HTYU*R`)*SCpRq`-AbioEo|0$v2gfG zeoinMo9#ArP*<>h7`DewmokABY>3T&PwC`!Z7ALlL~wZ>YURW_^e&$~;1>m678<@` zXo^AENcocTcJ@Pc_=F^tp5*%c{4?E(`xx$zF{r#Q;M9Ci>xJCC+=V-@9sYS4T_hmI z>G2-A-{Dx5c~+gp#4ZIkKlW0dc%qU{C7LFkduB{*omW!}PGNEpvK{j*eyph)yn&Ud z=TwLUB&2wz6Hbfx{3ff`uM4vs&nzAp1nZR9pqR6pGe2^U0Kzq2@O^;ydJDK26Yul~ zg`m^YD}e8*JJf&6L51<~^TXf}t_^HvW=2~$nq+fnG>uD-3G_+nLqfiTln3QZg)_i4J;2eV6bt2TWC0GhvkZ|m`ThgBkSNf%i;Yi z@;5c1h+xW*#I>M|oF>wIA zu~<@^;I;$r#Cq7@xaaXG7av?|zUiDQco)mRl_yN$iT?KqkoDPLU8Fh;F zaz`7JSYrA8&JLtjKDK>uTnQvtu3~mdcea1>PEXhgxhp`IrJd*+%4!5KM90ni`m@_( ze7yDCR45%f-?8U(sy-p2d1^HE#k*V3n7Ra4HeA;RC!K=ZfM)a|3MhA-jo5Mz>(bOY zJ7T*I&TaGdk`Bs}AF8-w-sZ7N=5lkqOb~i_oWe}l64+kzInJ<9;3Xkn9#}fz1(s2j z;EE{ri)pUMrKBQ+#sUma9gGu#K*;m+v`>W|-(UeI;5EvP;V6>&6#ok|isef~$&rv6 zNOO-i^DjlrSj#eh*MYb$BOX8MKRqk+kdx9GfRvc`zlQnxb(+>fN_Zi z#$J{)`>PK|Sff3Sdna)kDaoA=2O{_k0dm_IcJ=08Ze z-@m!kvvxy`)ORA1fLU#GEf~{@u;u|xk-Fk);A3uJ0Hi$gN4dQmOG%`Udqr7M!sn~3$$)A@DOWyuFingDH+)Ff0_UsP_s$S(*trUi*`4zB?#Z6lD7NK( z2gS_;ioW4LvmYUbjL($sa3D2V8W#IZwurnuV=)!Y ze81!pzuYCgdAa{ICC~fU-D~v<4DTaGb@ofekExh`7|IV}rO(5i z#U7xuw`oZ|8DxUDc=$yWa)bkyWO6!gISF}=1RYkZ;we#6;Wx(v77We%L0pWbj*J$-i}Fi9b|BHq8MO%{nOGGn;&qFig^=8C<2C8aQiV{X(f)Aqo z22F6KY7X}}VKh%&D2hsQzI<8ou4Yj&8D0_B%;Rz5U{(!1^J$Ln+FAsTd^5eH1m>{I zBNq1I9eDYMsmBsm{r-C0G4f5pO)-|TF_Fn*6Nhnto%cKRjuFP`YAd$(!?_q>pR61Bb z#-cwn9ASx{C}i2pNes&?67i1Ed-db|mlFHuIi;fGkn|WrfIibt9w*V7KTvi0(u|Zv zpXB*8(oYv>YR^Nyp43f?ot(@pNZ{?dchf!9HDc{?mIm}2P zG;8--$vJG_o!>b}p~)L=;oipmAgaV6!Kml>n5B1X~HMNn$XODkFH#56$mlrL{^ArgQ(WuA=3=v#IEdTY1JJ3=x$f2@sbDtfrt zY^fBm9ATgDy6M~H*Z+IzD^g6ZDY{#e^vz8G9p5vELN7D9+sS$?7ClmqwC2`DVs%hg zG_lPNS)yJy&qyA>DsDWuo25LtP~~Bo#Y;Nnxu~iN8*CyAI?GoM8!XT%7YRd_ZJd-W zAJw;kD3g7%rn549mGidxwK1@NU7Tl1L%{p7SZaI_q-bc|0H6 z;&obTwVBhxfonNKDsh!rSFS{+fI3%83;SC?HVtTOJ>fUw1C)@shl;FTht;c9lVS04 z$DR2h85*TG-Ol>om^R~vk>KY7h<+ZIL*wi~@46lwP})QAquUWyzvDOu4+ioqxtDMK z8(}&A;XPcXDWP26!0?feA`JA*g4xSm_sAqVh?b`EeD6unlR~Wx%H^N0vxR)nSdB$;M*IrV3( zk{E-XaURe1o{oOMU`K$BSDjm+>gJw5RQfei-`~^~6LKwh|MHg&MxbV>qr~&rLHFHilHRg zzx+?JJ^7`f?#fy;FUUbt7Ikb47S?3Tbw56dkQd7C;+{qF!x%U&omh zzD_cFBMMw@+(yF&tlG*CuMjVT>(x&_8w>e{8uNzVB=q||2i)10z!+3UY&3N*kD$YH zB=JdXNOgtNb41yjXNw>JPem`c%kwU5bFRs!Hn}y*)>&nvTf(1$Ve+dWpob;)6jG{-LdV?c*MJ5?8#)|e4*PNQd)~YdXPh)>8fb@F%tyFl2E0{y z)S_J%VWG7jkyHqNRz_D1f zS%X3yT|nN}RUj`U_)iA_IQYo0MAn07_N4F!Vk(p~WW!#WV1nT5UKJuwpS~keibA|^ zH8c9ge`HEY!hCZUx_%FumPhyq4}+`mUchUEXm;0)PJ(eiC{&N)R_LDBMWHH+V3Nc% z$o}KD)AT!%*3%C@)dc%S3uJ@$JHk&~Qby6OW_v+7AMF^pw+WE1@ywUmWZ$u*u%t%# z6Zv-W;p@`jOh$l47z0dTPg#nGiencpg<}^w_#ljqd9N9-sLfz}LjH~dDo^de{-9LK$BC-ezTe#r>mLv!rq}#k_ zOh;N{nn()CHtqo~JEu)$*>9U$CmZ0sNrQJM52x?hE=c8sVuXC9&CJ!{F>Qx_im;>h z+Fgwg@U0UXK=#G1!9spJ5_H6rC~a2q(_%>|hr) z=FiWF9&QkHcZ+6o9USoF==Kq;{$9wCV1dBC4?c?g2qS;}u=Uvz-6k{9s!2+vo$!7I zRB;Du*tdB8Y1HR(#1VFBngsXly=DF!n`kAj{jgT`^+S=enp_rg`0JOI4NgWSnt9N( zlu|oj`3DnA1)4B1`tLbvE5JNdj*DL|Gz~;_U4Z!U7N}W|9eI=C&sv}^jZrPlV<0Q( zL2iFm2c^CPhpQi)O(X^!!Pq`)<$v4Isa7y9y)Jb4bgtbjW76`=4Gso)+Y?qf* zG@u7fz*lg`|8=pbG;*6KPVMt4sgXe}aCh40XfZl?`cUKu&jk-q>RmzjSu@{eG zlQ;h|{Si01x9S5{v!N(=+-FnaDR;aOh!Z4b;E*?HXv6q4kzzUgyGYy)&Ag^ z?y{KXTR89Ux2Mu6EqG7~QsPnKh?7R)B04*QRWKi1+p5cg|>h3e#NAa~i31Ki@UDKu$g47mN z04<_U9S=A%9O#yL!$}M-_ANmzJo^mbHrlyvo$pxmkM6%YNQEEb;5I9`p!gQaFPVCa{lx z7+gXbv|_(hWDr||n7LEnpZ$e3yz%=M4@UcA%->rLzK##~4&<}=u^1s2(wfkft{@B{ zh=*46s%)OT1yW1CES6hgT5oT@dB|x9vXZSC;71WR))hZ;S*W0ma~8^~w9NdZ(O!_W*m8FMIK%tJctY$_f?lq zUo8KeQ@4aETK1-=U1!OJ0kFB{u872ty^Mg8uu7eQoJkq!Ro7X?!Vg855Cqpj3y-BZ zVZ7&TK;z7+Pc^cUU#M#?uXh)yeTpjhL49TVKX}}?LAn9xucS63&FG_7i$Q5k6Bp2x z;2Gn|BAmZV8aVjVa}}5iyvjIe@aTOmT6=}efs7M|D6fk0Cc(!y`4uQVwil?Y=s#0R zidwyWh9bb0i{$n2h@RGAH|!eaZvF?$^Tvpk)V0(onn&l3tQuu<`}s3U=6?KkFayyV zx=ZoSmqeCC7Q+=bj-K@$VTk^-{fFGZq=6tYC}73*m3{Im{9rYczHf+QQDV`5v02U} zney)$m?asW9PSrePWi(z-uWt+KSIs>)=srR5?qj)k3u$dI*HHKK$VK@Ya%?18lM`D zLU@i|5D$bF&eI&{Mpj%y_W)e^ZKC@VDZeL+ZYbfcweX^;FcD7lN~gCy_p6|ayD9L& zN!7DxYBtjocynoWtYPqGf;*?+svK*W_HjK}YM43I^RKXNl362c6a~E%>aa@+Y|@E)VMH-c7DC zDdq!{y5wzHfxqL7>1lA%Q^??CWzO5KmHE!`;L$*J+nJeB5b)X$Q-tty#Vil8y3Y|w z+TFn5WBc2*$W{$;A5+5*xf5WtV|CcfU&B`kNJ9Syx4-YUV(p5fzj4mdFoxc!`^sAz z>;8Q=M#z;e~`&n$GpZDi-g>H~^EgaJcT|RfFt)oM@ zd$;{PZUZFOXSqxW{;2V;o8*IbRpv&j-d$F(2Y!xgP8IfR4$HS(y3Al@ta<1H5Yc!N zihp3vH?@Sz6he5ZZH~7kvF-DV?Vm9W0G)7V;!a_S{_2~|r4Lp~_}0oRIXHAyLpFnS zcrrlHTSqy~z9X78X981N{CY8qw3FlZza;cis}LD4!qO3MUWydcV(CFG>v|1UqkKJm z19{9vQl}y$QDGDe%O@gVxQqXp&(Fn^|FZczptrM!kt zja(01o2B{^Ali2Ew^pdAgr3>R$2{R*0iLE%V|IOqOp>4%b4+mCYB_09t5F$cjUYdk zXXllh@)_RE080r&)?yIrr`oTBh*Ue+W;-OL6C55F0h8_E{h(U0(u~-uKV)yh{#oK5 z>Yf7Bg5J1M)t&N`%8}8|V8|K$KC2H9FS{ovc+>icLci}-uJmW2K z@CsL=BQ6(@D7m^5k)C@lV9a!=I zs|Z7R(ErOQ$zDZr_28nCtfCE0fj@ms*SM$uv-&$L;hR_~0w3;?wm^ca;VQ|5|5>`+ zB@L>lJ9MTMVyLYI4lw4lkW6Thr3yW}i zqi>Do+h2MwwFEmY_ynXZY9|ynr<$LrCcB8Ks6zb*M2DuI=5G=84nb*8W~EI1XPu)Z zPwEnIuLqwx12Ln;*)6}1Zc*)DY<;IM9*o7K#a=YH))du0y4Em@01(D|<>4K*15;45 z+q_9EvW)0zMh%=n7;R=EOBd7^X_+k~J7(#GYt>v*1`Ki^r&ws}Tho53&=f6Q}hGyiU7dmmJnB9n;_(6jj?leQCWM~CJ&sRg1b0`R;UA(s+o`R7X zJ3F0exicL?V-Ay9mmN=$rLXb3Y?*g&?XFFrmbzXZ)*Ob$j&o!6R@l91I1_U3jZWKS zhxy59NDHh_VUP!Mgr#ovaGaMiDCV*|r2bq|d!uXlWbjofcWPtFO~3T=cUQ-)CB}XM z%z5V?O-}>reD2c&j{t!QLwZGSPs&P7>a|#W=MNAwVrQ(qPBAWVfUyJ@(=DyGRwU1~ z+|tz_;y;a9ONxOhU~4(e&X-Up^x-lPTN<0^WMAcxL?Z*MTw(e)#J zG}2CwW@mcAIM++);wy_KOQw#OQ92hnviW_qCY3%oIgtj{vtX4t)O`qc*Mt`*#6f+V z0`H#VjFpUNs|Y<|k%!G4E;a5Xos|i*4!-C%@FcfM-#Ix$)>K?fr#}P0sHc-* zO)7kScm9cdTAD!^!v96 z+*hAe+K<*y_o|@0-qi^H7F#&;!uGO<9iEYM>)Z z?%TRPQqGyePIM}YP4^s^Lw_BaAMIcw`#feD^vUBPx-%|wCI@hY=4=@kOqeyskPzf3;fF}sedpdjE=|4I-|GoLgUjvr^Zkzny z%p~RiX%3{bhwrEn!N^}e37{eYprwhgdr|BdH&p-QIJKY5a(6v_=g>F*%atbM@w^C7 zIA`6~lg8q5|HD8Z-6JsgYmUPU38Kh&R3!;)z;{%xF9%69Axr^3qkmA@PVhAW$iKL7 zW_HZ2DKH?gpQ5gz5g9`G56@w4+Ii){wb{-!Sl^hq+&Cz-j++c1I%I9$IWsmi6M&qg zJ7kEQUAq$<)}n6&wE}SUPYXuQ*-ZaMshDZAY)zN{2i)F{bm(FO^c(%pMYg6Z!_?K) zKLcoZE8M?u_&Z_1D*05xFhO$0yWRJG{pFo74Fy0(5*`S0S^3CLl+Lum#xsl~aMNCj zaZajyN>>u7WQnig_A#NmNrFE?I9ZHs#KN@Fk`2Ou5l}`7OVeHup)4ig^83V>Om~1Q zm)zF#0_0_b2(B*@CQiCW^nV!rn&OOe6LV{h=%LgdTC$zpDR{JJAl|{!Blz&a-`|dN zq8bGV4-Rr!*K`NU0W!itHX&LX&0k_7n@BbxePQ5V`A~TYAl|EhA1KSJ05pWC%FF){ z7KAXj+{()rb=?MW??TDQ9}_B5r2LEi2mUT#O7!(!c$5-AyUt=xodRBu$p1>4xQ{8L zn{<#^BX(3kiAJ3?tWL>ANd9QS276RQDA5s(k zUwKO@(``LWtrMTIKKtuG<&20Xxb~l7r48Xz?FBpI2tl3;Jf{o^+IvI(h>X%elW1m9 zzSC`_+#mQKsjp8G%*0FlwjbRAGEWe-11QMf&GF$V7#&Iej`Cg7b4%{J>$f%hNy35X z|JFjPT3$s@`tSiX?LiWl#;-@KpOf2@_b!lNXp30nw8`a!7jiGy+bb0B2yi_VBts%7ByL z2X3INyu+XPOc>~1h~B*Z7J=P#xf5(o z1D0cPY>81>2w+JYJdXY(lQc?Df6erKhC<)XQh@1ob%4=mVkp3<77hG9It&~4v65T8 zFHm)^%Ks>I^wS6j>#1*r;?DWbdP%lGNt+0jy8lN`_Ky?EUEOhPxw68n`1*F5-?5X3kC&iLY*FlWdqO#d@A!I)- z`lk82@IlGLmO>G&`r^CF4uVNssXwGvc2#E+9N>5mp^R@WH3|G>V^W~{ot!N%9=7W3 z0`u3$9i{#m1jNk4NsN|u){Mq#E%KV!g_)Xt#f!X$$_^mWUv<1Zju@W-c>JlJ`t)Ys z%s!vw<)wnnff-}+h({=&sQ2aP10p?;ll2sqJj(F$lyG1}Btre0`j{VYZg{BrWW4$d zUCBw+|MhIEbI)|;m9Pj<_8s_w&Lq~5*XCf?uSm7UxwM4erMq(0s ztxjeLE_vb0@Jsrqg0vFb8A|wYT2&LN=slh!7S+vdy;u>Bk$52*mZQ{+Z9_Ns1D)AE z7k&sEg}N1bF?Vcn1n;0@Tyko^&LmDBQ<5yuxNP^xUPjae_U9wCclO@^M}JJOiHN_` z2heg>ZdEACo&^;26jb>fIwX%+=G|tpG9s!RHbQJ>q&aT9y~yM^otk_AtXm35EVMQYny!qGS({J@{oKn43NH0@F^_er z+KaUo0k1`)Lnz>YR%MFycuU9N0ql&|80sp|A-aerw?FG@*ObM)rguL93EQiVcR5LE zmg)mo8$9Bkn?$R{=?w-+T$iLTGJL*nYogBh)KcKdo)bBd2;tCF+n+{JklLX6&-b6f zp`d{MAABm37kluGDx&?x)Yn%vRkoUi{qE-ZNY9Jd`(&8d@I?5Ww4NT7{!2x^Z9+gd z6Da81ZikzH#wv3Q5ewHB-GRMA4B zbN%M;J70}86mIbk=S#~1J{BtP<2ZEia$Uc{U@C_jW7Vq>KXVQ@XD8z=4}PgtA#I#e z1Z-BqFR(`=86R%FozBdE(*)AG$i3^nH6}kU-7v13I7pGf@TSh_rr)7CL&K`!mG-gb zTFG0->~Bnp!>EB2Ej>&Bxnl!yZ@e_j#&}Wi=!Ttci@~@u;8arp2+588u;w>y=re|$ zv{a1=Bv1O#X*D&mUpPh1>&BkSKGqX3`f5+PGuPufhDs9>$p4c8kVU1|zPv(u(~(tz z`m+jaH3j1{Q&{A}Q66cpwJtT7SoK3%v@;~+vBN0Q6gKqtQiRlj-Q>Q$4UTVafcD*9 z3#T3oBHJctt#DQ3f3`6zawp;SbYhQix%*y9~I zkcJSm_l)@`1v;Urh>0t`w_=O{?t zb8)Qg_As^%XpJ!DQ^!eoY#vKQd%%_Z#}|{wK*BDeGy9ud&ubqaC$o(HctGz zGX$!yblxfpg$44`;zB1OZy=HBb~LfIXntkuzB$e=Uuw8L5g+7ZJ|}fWf1<=g9vSS! z79k%*5_!N+yWl;Q$O14VCm`GqYO$I2ov%kuB+2zo zMbxkh1$*!w3T#OTrjL$le8w^|>>Uu;Glv`>qzTML1Ss?o z9<5lnm>jt9$^bBoF^q3d4|r93b!HqQ75s)8{irZdn6xnEH%vcg6-dW_sAqrsM^0 zZzJ6y;y;E$ZjZH2OD}XKFVHORQF&k>+oRAXMSN~G>j2RpWfpD68}Y3cQO{R9Qomn2 za%$+1o#TD1u`$(Kv;R)CI%-tmD&<@Ec?#E{47%+me7DV6Xb zoEb3`!7^HD>N7mw(TLaz_8H|QXhI|Xu)|32gKSApI;qKONJfh%mGmRa(#)zm%1br& zv(JOa0s&LX*fuMhq{mfX3xHQt`8gAEM(=HmkqKb1fLjZ*u;!b zWPAr`ZwP?m!!~UbECjd%A@U%#GnuaeiyM269fnmMqnc>)(;G8IcOP*SEJr|lmwxiu zSkbz8Y6gS05w(z~Q)h^8gG-3hY#vg^vH$G+oURrV6Wk<%W#oC8d zy{eIvAf>oKZv2D<+J3}_R?&K(&o=Sz=Em)s+}l$YiV=K(70~42uHP~K-tg6SHg>yN zA?cO4cq`A-ZO7bClK5M}Z!Q*_j;%Ig3*6Qhr9oD_=KE&sfbWT0qkF(%)kn=pN}+f9 zu7J+9jdoE9)vL9phEY^c*EmzY`DJ{S{YcFdvXFWeEr)8Q5b}$H-Q&BlA}k~E6Hssr zAg!yJgiIjt4Sn-Q%(%EAZ16lxm_AR1-mQYRb}qelI-Uk_UJ%Sb^PdzhOGBSo>wwDk zq*SMwd1`G`lcc51tNbcCY3H2~I4n;Bldgj* zxKK|l{k8vPuKDu<)np)yZW=~MHIlSVH!+Y!+9YaB^eBlpc!5&vtHU25)g-uKEtQ;( zAg$7sjad8y9m&-$Za?CWs4GtTeNmA6hQN5Ce!@)krW8PS()q4i{*8oILJ0xKp0W4} zTMQ=W_D`@EaZAzfYSiBibdlN=vM+W^$oC6V3ACpLon6BCJ?Re7v+01Th_ZPAdue}U zBAfb=%vmJ#mw$6ngL{Fqkh$XXp&?gMI455k-Uf7r{C?aa>eZ;})tdVv0G%$9> zY$i4FWs}cQRyUouuDr!=uFXi*HuuJgysF;D-QKe+Y71l-#kg6`sto7{O^?+EhcB~GiaFEFYECqHF1*4XZEHaR zs3T=DR~iUE&ZmArOabv?ly-$jSIIboy& zKK<&~C~a@bAM|s#o5f3@{r)+E|0))xF(zlL5|*#NIjJ{0;SF%;b4m4@CfLhp&rcfZ zt>2|N8)vH!jSlRH8Y(}V?6cl|iL1x&psWf2ApzjVXT)^-&Wm6~{r@o1Ad$0mup$ z2qnt=7WBsR&$A|wZ#*AFN03q`z?v^vDD+Ju5vss)Xko*#>M^HYmHL+5)g>;@mLM)h zz%;ZeKn5#eH+4s`X6AB6yXAdAIW3-D;-M@Fsvvt}5HJxT&QktSY%{(Aq;A}6R~p(MEpFdqZ= zu@j2!&dWbWFeUU2Dl&1-DF=K0+}kP`fec{N2H`PmTc-Q=#XdDW40r04Ou zsR|F)tML>8h_bq?Z67Q210PfO`T;~IKLvVf+qBC0=tc6J3178W@$I-Xd$zZh#5}2! z&kgxO6&D|=8US%wO&v7yD14l;C{mNq;J4)3o)U`S1sr*NVV9S^-@=aA;4QN6dNh)C zv{O~Px}}vv5m_jtIAHwS-VPQ|Z1CccuF&%Jm;8x22Uq*Gha^fko?Jh}tJ94NQ>{C} z;xE~P8%7IicXGz2vpI`D@DxlmSJkKC0?8ipmTh^3#8oe%&mPA7=bapVq^*==q_tw{ zX9s1nV>`Y)L{C|MixaNCeG zmoGf@cZ2&*0l52Bk;By7vo~8)WlN0t5Tw6=#dpW%RW!o#dzJXuTtT3=kN@TQ?P|Y| z0S7mU==e1K;LIw?-+N!I6xcT&RrQ>W_#I5~+Kg1V8&UG8|6BXN9CL*|Wooae*i2i~VQ!uX{SzCBTD=s25@Fn}=LoTLuj5xJPJMM`&n zp@-vdU!)h)P-bb}2M6Av^IMdDyHH9bGs#x{7@h0@?pIDH6RUp9;(O94ZWJ^wTDrh zt~II%%aobk(^bQkd{Yqw${pjjpVYYScImTja{QusNlez8+L|qP?&$PAR59hW>5)lV&DBMuB;j^veC7!aWwjZ=oSHb;fV%-ol z)vxhP&Zf+z3`!;F@~l|5=3O!O2gBQmKEau+8hUsy@TufKlDlT+K~i2c`5dZaU4W7P z64nIps!Mn>Q{$j){yITB^c%vYK2V;JqLAfX>Ih?hWKaPgEuu%vvDvyWWhaCp5>?$Naov7!9@}>;Jd_751+A`bm2)KsPVrv}j;_mky3TGuK%z zOG$yYC###$@=Tt-IMC5g6+}!>{)>4Ojl;*D>wV5rC#tn(&Q5_acdd%cB8eS%0Db7Ld=J9 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 ZLi%oAV{P28j%dI~Szbe~Le@O^e*tKtWA6X} From 9573f7828baa4b969796625a2869cec3ba840dc6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 15 Feb 2025 21:52:41 +0100 Subject: [PATCH 65/66] Update action description in ecovacs integration to match HA style (#138548) --- homeassistant/components/ecovacs/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json index 723bdef17f8..44c51c7ae43 100644 --- a/homeassistant/components/ecovacs/strings.json +++ b/homeassistant/components/ecovacs/strings.json @@ -250,7 +250,7 @@ "message": "Params are required for the command: {command}" }, "vacuum_raw_get_positions_not_supported": { - "message": "Getting the positions of the chargers and the device itself is not supported" + "message": "Retrieving the positions of the chargers and the device itself is not supported" } }, "selector": { @@ -264,7 +264,7 @@ "services": { "raw_get_positions": { "name": "Get raw positions", - "description": "Get the raw response for the positions of the chargers and the device itself." + "description": "Retrieves a raw response containing the positions of the chargers and the device itself." } } } From c75707ec79f50e8cc24a06c61b43b9ee610eab3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 16 Feb 2025 00:29:38 +0100 Subject: [PATCH 66/66] Use correct inputs for relative time and duration options (#138619) --- .../components/home_connect/__init__.py | 33 ++++--------------- .../components/home_connect/services.yaml | 24 ++++++++++---- tests/components/home_connect/test_init.py | 2 +- 3 files changed, 25 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01eb6e8fbea..a020b2370b9 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Awaitable -from datetime import timedelta import logging from typing import Any, cast @@ -74,6 +73,9 @@ PROGRAM_OPTIONS = { value, ) for key, value in { + OptionKey.BSH_COMMON_DURATION: int, + OptionKey.BSH_COMMON_START_IN_RELATIVE: int, + OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int, OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool, OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool, @@ -92,18 +94,6 @@ PROGRAM_OPTIONS = { }.items() } -TIME_PROGRAM_OPTIONS = { - bsh_key_to_translation_key(key): ( - key, - value, - ) - for key, value in { - OptionKey.BSH_COMMON_START_IN_RELATIVE: cv.time_period_str, - OptionKey.BSH_COMMON_DURATION: cv.time_period_str, - OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: cv.time_period_str, - }.items() -} - SERVICE_SETTING_SCHEMA = vol.Schema( { @@ -156,10 +146,7 @@ SERVICE_PROGRAM_SCHEMA = vol.Any( def _require_program_or_at_least_one_option(data: dict) -> dict: if ATTR_PROGRAM not in data and not any( - option_key in data - for option_key in ( - PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ) + option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS) ): raise ServiceValidationError( translation_domain=DOMAIN, @@ -190,9 +177,7 @@ SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All( .extend( { vol.Optional(translation_key): schema - for translation_key, (key, schema) in ( - PROGRAM_OPTIONS | TIME_PROGRAM_OPTIONS - ).items() + for translation_key, (key, schema) in PROGRAM_OPTIONS.items() } ), _require_program_or_at_least_one_option, @@ -486,13 +471,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: elif option in PROGRAM_OPTIONS: option_key = PROGRAM_OPTIONS[option][0] options.append(Option(option_key, value)) - elif option in TIME_PROGRAM_OPTIONS: - options.append( - Option( - TIME_PROGRAM_OPTIONS[option][0], - int(cast(timedelta, value).total_seconds()), - ) - ) + method_call: Awaitable[Any] exception_translation_key: str if program: diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml index 50e50afd598..91b0089d653 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -387,10 +387,14 @@ set_program_and_options: collapsed: true fields: b_s_h_common_option_start_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s dishcare_dishwasher_option_intensiv_zone: example: false required: false @@ -493,10 +497,14 @@ set_program_and_options: mode: box unit_of_measurement: °C/°F b_s_h_common_option_duration: - example: "30:00" + example: 900 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s cooking_oven_option_fast_pre_heat: example: false required: false @@ -561,10 +569,14 @@ set_program_and_options: - laundry_care_washer_enum_type_spin_speed_ul_medium - laundry_care_washer_enum_type_spin_speed_ul_high b_s_h_common_option_finish_in_relative: - example: "30:00" + example: 3600 required: false selector: - time: + number: + min: 0 + step: 1 + mode: box + unit_of_measurement: s laundry_care_washer_option_i_dos1_active: example: false required: false diff --git a/tests/components/home_connect/test_init.py b/tests/components/home_connect/test_init.py index 9e514824147..5e309a7446e 100644 --- a/tests/components/home_connect/test_init.py +++ b/tests/components/home_connect/test_init.py @@ -152,7 +152,7 @@ SERVICES_SET_PROGRAM_AND_OPTIONS = [ "device_id": "DEVICE_ID", "affects_to": "selected_program", "program": "dishcare_dishwasher_program_eco_50", - "b_s_h_common_option_start_in_relative": "00:30:00", + "b_s_h_common_option_start_in_relative": 1800, }, "blocking": True, },